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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ single-configuration integration to `default` and keeps only the first.
- macOS reports one Now Playing app and has no per-app volume. Do not emulate either.
- Linux has no system-wide current player; the one that started playing last stands in. MPRIS volume is
the player's own, so a player without a `Volume` property gets no slider. An app id is the bus name
after `org.mpris.MediaPlayer2.` without a `.instance...` suffix, so every instance is one app.
after `org.mpris.MediaPlayer2.` without a `.instance...` suffix, so every instance is one app, unless its
process was launched with a window class or profile of its own (a web app, a second Firefox profile):
that instance becomes `<app>@<variant>`, read once from the bus owner's `/proc/<pid>/cmdline`
(`LaunchVariant`), and is named by the `.desktop` file of that class if there is one. A plain launch
never gets a variant, so plain app ids, and the widgets bound to them, never change.
- Sound devices are an `IAudioDevices` behind `IMediaPlatform.AudioDevices`: Core Audio on Windows and
macOS, `pactl --format=json` on Linux (it ships with PulseAudio and `pipewire-pulse`, run with
`LC_NUMERIC=C` because some locales break its JSON). Only Windows has communication defaults; elsewhere
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Spotify, Firefox, Chromium-based browsers, VLC, Rhythmbox, Elisa and most others
`mpv-mpris` plugin). Nothing has to be installed. Macro Deck has to run inside your desktop session to
reach the session bus; otherwise the integration shows an issue with a **Try again** button. Linux has no
system-wide "current" player, so *Any app* treats the player that started playing last as current.
A browser started as a web app (Linux Mint's Web Apps, Peppermint's Ice) or with a profile of its own is
listed as an app of its own, named after the web app; all other windows of that browser are one app.

## Usage

Expand Down Expand Up @@ -162,8 +164,9 @@ Everything runs on your computer. The plugin sends nothing about you or your med
- **Linux:** players are read over the D-Bus session bus with the bundled
[Tmds.DBus.Protocol](https://github.com/tmds/Tmds.DBus) library (MIT). A player that reports its cover
as a web address (Spotify does) has the cover downloaded from there, as the desktop's own media controls
do. App icons come from the apps' `.desktop` files and the icon theme. Sound devices are read and
switched by running `pactl`.
do. App icons come from the apps' `.desktop` files and the icon theme. A browser's launch arguments are
read from `/proc` to tell its web apps and profiles apart. Sound devices are read and switched by
running `pactl`.

## Development

Expand Down
17 changes: 10 additions & 7 deletions src/SystemMedia/Platform/Linux/DesktopEntries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,12 @@ private static bool IsFileName(string name) =>
}
}

// Desktop actions carry Icon= keys of their own.
internal static string? IconNameOf(IEnumerable<string> desktopFileLines)
internal string? NameOf(string desktopEntry) => IsFileName(desktopEntry) ? ValueOf(desktopEntry, "Name") : null;

// Desktop actions carry keys of their own.
internal static string? ValueOf(IEnumerable<string> desktopFileLines, string key)
{
var prefix = key + "=";
var inEntry = false;
foreach (var raw in desktopFileLines)
{
Expand All @@ -63,9 +66,9 @@ private static bool IsFileName(string name) =>
continue;
}

if (inEntry && line.StartsWith("Icon=", StringComparison.Ordinal) && line.Length > "Icon=".Length)
if (inEntry && line.StartsWith(prefix, StringComparison.Ordinal) && line.Length > prefix.Length)
{
return line["Icon=".Length..].Trim();
return line[prefix.Length..].Trim();
}
}

Expand All @@ -74,7 +77,7 @@ private static bool IsFileName(string name) =>

private string? FindIcon(string? desktopEntry, string appId)
{
var iconName = IconNameOf(desktopEntry ?? appId) ?? IconNameOf(appId.ToLowerInvariant()) ?? appId.ToLowerInvariant();
var iconName = ValueOf(desktopEntry ?? appId, "Icon") ?? ValueOf(appId.ToLowerInvariant(), "Icon") ?? appId.ToLowerInvariant();
if (Path.IsPathRooted(iconName))
{
return iconName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && File.Exists(iconName) ? iconName : null;
Expand All @@ -86,7 +89,7 @@ private static bool IsFileName(string name) =>
return candidates.FirstOrDefault(File.Exists);
}

private string? IconNameOf(string desktopEntry)
private string? ValueOf(string desktopEntry, string key)
{
var fileName = desktopEntry.EndsWith(".desktop", StringComparison.Ordinal) ? desktopEntry : desktopEntry + ".desktop";
foreach (var directory in dataDirectories)
Expand All @@ -99,7 +102,7 @@ private static bool IsFileName(string name) =>

try
{
return IconNameOf(File.ReadLines(path));
return ValueOf(File.ReadLines(path), key);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
Expand Down
56 changes: 56 additions & 0 deletions src/SystemMedia/Platform/Linux/LaunchVariant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
namespace SystemMedia.Platform.Linux;

// The MPRIS instance id changes with every start, so a web app or second profile is named by its launch arguments.
internal static class LaunchVariant
{
// The variant ends up in the app id, its stored name and the instance list.
private const int MaxLength = 100;

internal static string? Of(IReadOnlyList<string> arguments, string appId)
{
var variant = Value(arguments, "class") ?? Value(arguments, "name") ?? Last(Value(arguments, "profile"))
?? Value(arguments, "P") ?? Last(Value(arguments, "user-data-dir"));
return variant is { Length: > 0 and <= MaxLength } && !variant.Any(char.IsControl) &&
!string.Equals(variant, appId, StringComparison.OrdinalIgnoreCase) ? variant : null;
}

internal static IReadOnlyList<string> ReadArguments(uint processId)
{
try
{
return File.ReadAllText($"/proc/{processId}/cmdline").TrimEnd('\0').Split('\0');
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return [];
}
}

// Firefox takes options with one dash or two, followed by the value or joined to it with "=".
private static string? Value(IReadOnlyList<string> arguments, string option)
{
for (var index = 1; index < arguments.Count; index++)
{
var argument = arguments[index];
var name = argument.StartsWith("--", StringComparison.Ordinal) ? argument[2..] : argument.StartsWith('-') ? argument[1..] : null;
if (name is null)
{
continue;
}

if (string.Equals(name, option, StringComparison.Ordinal))
{
return index + 1 < arguments.Count && !arguments[index + 1].StartsWith('-') ? arguments[index + 1].Trim() : null;
}

if (name.StartsWith(option + "=", StringComparison.Ordinal))
{
return name[(option.Length + 1)..].Trim();
}
}

return null;
}

private static string? Last(string? path) => path is null ? null : Path.GetFileName(path.TrimEnd('/'));
}
21 changes: 19 additions & 2 deletions src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,14 +348,31 @@ private async Task PollAsync()
Replace(current);
}

private static async Task<MprisPlayer> ReadRootAsync(MprisBus bus, string busName)
private async Task<MprisPlayer> ReadRootAsync(MprisBus bus, string busName)
{
var root = await bus.GetAllAsync(busName, MprisBus.RootInterface).WaitAsync(_callTimeout);
return new MprisPlayer(
var player = new MprisPlayer(
busName,
root.GetValueOrDefault("Identity") as string,
root.GetValueOrDefault("DesktopEntry") as string,
new Dictionary<string, object?>());
return player.IsInstance && await LaunchVariantOfAsync(bus, player) is { } variant
? player.LaunchedAs(variant, _desktop.NameOf(variant))
: player;
}

// A sandboxed player (Flatpak) is owned by its bus proxy, whose arguments name no variant.
private static async Task<string?> LaunchVariantOfAsync(MprisBus bus, MprisPlayer player)
{
try
{
var processId = await bus.GetProcessIdAsync(player.BusName).WaitAsync(_callTimeout);
return LaunchVariant.Of(LaunchVariant.ReadArguments(processId), player.AppId);
}
catch (Exception exception) when (IsCallFailure(exception))
{
return null;
}
}

private void Replace(Dictionary<string, MprisPlayer> current)
Expand Down
8 changes: 8 additions & 0 deletions src/SystemMedia/Platform/Linux/MprisBus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ internal async Task<IReadOnlyList<string>> ListPlayersAsync() =>
null);
}

internal Task<uint> GetProcessIdAsync(string busName)
{
using var writer = _connection.GetMessageWriter();
writer.WriteMethodCallHeader("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetConnectionUnixProcessID", "s", MessageFlags.None);
writer.WriteString(busName);
return _connection.CallMethodAsync(writer.CreateMessage(), static (message, _) => message.GetBodyReader().ReadUInt32(), null);
}

internal Task CallAsync(string busName, string member)
{
using var writer = _connection.GetMessageWriter();
Expand Down
15 changes: 14 additions & 1 deletion src/SystemMedia/Platform/Linux/MprisPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ internal sealed record MprisPlayer(string BusName, string? Identity, string? Des
{
private const string NoTrack = "/org/mpris/MediaPlayer2/TrackList/NoTrack";

internal string AppId => AppIdOf(BusName);
internal string? Variant { get; private init; }

// "@" never appears in a bus name, so a variant cannot collide with another player.
internal string AppId => Variant is null ? AppIdOf(BusName) : $"{AppIdOf(BusName)}@{Variant}";

internal bool IsInstance => BusName.Contains(".instance", StringComparison.Ordinal);

internal bool IsPlaying => string.Equals(Text("PlaybackStatus"), "Playing", StringComparison.Ordinal);

Expand All @@ -33,6 +38,14 @@ internal static string AppIdOf(string busName)
return instance > 0 ? name[..instance] : name;
}

// A web app's own .desktop file is named after its window class and gives it its name and icon.
internal MprisPlayer LaunchedAs(string variant, string? desktopName) => this with
{
Variant = variant,
Identity = desktopName ?? $"{Identity ?? AppIdOf(BusName)} ({variant})",
DesktopEntry = desktopName is null ? DesktopEntry : variant,
};

internal MediaSnapshot ToSnapshot(DateTimeOffset readAt)
{
var length = Number(Metadata, "mpris:length");
Expand Down
12 changes: 12 additions & 0 deletions tests/SystemMedia.Tests/Linux/DesktopEntriesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ public void A_desktop_entry_that_is_a_path_is_ignored()
public void An_app_without_any_icon_has_none() =>
Assert.That(new DesktopEntries([_root]).IconPath("unknown", "unknown"), Is.Null);

[Test]
public void A_web_app_is_named_by_its_own_desktop_file()
{
Write("applications/WebApp-SpotifyWeb3778.desktop", "[Desktop Entry]\nName=Spotify\n\n[Desktop Action new]\nName=Other\n");
Write("outside.desktop", "[Desktop Entry]\nName=Outside\n");
var entries = new DesktopEntries([_root]);

Assert.That(entries.NameOf("WebApp-SpotifyWeb3778"), Is.EqualTo("Spotify"));
Assert.That(entries.NameOf("../outside"), Is.Null);
Assert.That(entries.NameOf("unknown"), Is.Null);
}

[Test]
public void The_data_directories_follow_the_xdg_defaults()
{
Expand Down
45 changes: 45 additions & 0 deletions tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using NUnit.Framework;
using SystemMedia.Platform.Linux;

namespace SystemMedia.Tests.Linux;

[TestFixture]
public sealed class LaunchVariantTests
{
[Test]
public void A_linux_mint_web_app_is_named_by_its_window_class() =>
Assert.That(Variant(
"/usr/lib/firefox/firefox", "--class", "WebApp-SpotifyWeb3778", "--name", "WebApp-SpotifyWeb3778",
"--profile", "/home/user/.local/share/ice/firefox/SpotifyWeb3778", "--no-remote", "https://open.spotify.com/"),
Is.EqualTo("WebApp-SpotifyWeb3778"));

[TestCase("--profile", "/home/me/.mozilla/firefox/abcd.work/", ExpectedResult = "abcd.work")]
[TestCase("-profile", "/home/me/.mozilla/firefox/abcd.work", ExpectedResult = "abcd.work")]
[TestCase("--profile=/home/me/.mozilla/firefox/abcd.work", null, ExpectedResult = "abcd.work")]
[TestCase("-P", "work", ExpectedResult = "work")]
[TestCase("--user-data-dir=/home/me/.config/chromium-work", null, ExpectedResult = "chromium-work")]
public string? A_second_profile_is_named_by_its_profile(string option, string? value) =>
Variant(value is null ? ["/usr/lib/firefox/firefox", option] : ["/usr/lib/firefox/firefox", option, value]);

[Test]
public void A_plain_launch_is_no_variant()
{
Assert.That(Variant("/usr/lib/firefox/firefox", "https://example.com"), Is.Null);
Assert.That(Variant("/usr/lib/firefox/firefox", "-P", "--new-window"), Is.Null, "-P alone opens the profile manager");
Assert.That(Variant(), Is.Null);
}

[Test]
public void A_class_that_is_the_app_itself_is_no_variant() =>
Assert.That(Variant("/usr/lib/firefox/firefox", "--class", "Firefox"), Is.Null);

[Test]
public void An_oversized_or_unprintable_variant_is_ignored()
{
Assert.That(Variant("/usr/lib/firefox/firefox", "--class", new string('x', 101)), Is.Null);
Assert.That(Variant("/usr/lib/firefox/firefox", "--class", "Web\u001b[31mApp"), Is.Null);
Assert.That(Variant("/usr/lib/firefox/firefox", "--class", new string('x', 100)), Is.Not.Null);
}

private static string? Variant(params string[] arguments) => LaunchVariant.Of(arguments, "firefox");
}
19 changes: 19 additions & 0 deletions tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ public sealed class MprisPlayerTests
public void Every_instance_of_a_player_belongs_to_one_app(string busName, string appId) =>
Assert.That(MprisPlayer.AppIdOf(busName), Is.EqualTo(appId));

[Test]
public void An_instance_launched_as_a_variant_is_an_app_of_its_own()
{
var firefox = new MprisPlayer("org.mpris.MediaPlayer2.firefox.instance_1_918", "Mozilla Firefox", "firefox", new Dictionary<string, object?>());

var webApp = firefox.LaunchedAs("WebApp-SpotifyWeb3778", "Spotify");
var profile = firefox.LaunchedAs("work", desktopName: null);

Assert.That(firefox.IsInstance, Is.True);
Assert.That(firefox.AppId, Is.EqualTo("firefox"));
Assert.That(webApp.AppId, Is.EqualTo("firefox@WebApp-SpotifyWeb3778"));
Assert.That(webApp.Identity, Is.EqualTo("Spotify"));
Assert.That(webApp.DesktopEntry, Is.EqualTo("WebApp-SpotifyWeb3778"));
Assert.That(profile.AppId, Is.EqualTo("firefox@work"));
Assert.That(profile.Identity, Is.EqualTo("Mozilla Firefox (work)"));
Assert.That(profile.DesktopEntry, Is.EqualTo("firefox"));
Assert.That(Player(new()).IsInstance, Is.False);
}

[Test]
public void A_playing_track_becomes_a_snapshot_with_its_timeline()
{
Expand Down
Loading