From cd464ce7c5e5b240e5d2ae89131ad28894e7ef7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 8 Oct 2026 12:09:41 +0200 Subject: [PATCH 1/2] fix: list Linux browser web apps and profiles as separate apps --- AGENTS.md | 6 ++- README.md | 7 ++- .../Platform/Linux/DesktopEntries.cs | 17 +++--- .../Platform/Linux/LaunchVariant.cs | 52 +++++++++++++++++++ .../Platform/Linux/LinuxMediaPlatform.cs | 21 +++++++- src/SystemMedia/Platform/Linux/MprisBus.cs | 8 +++ src/SystemMedia/Platform/Linux/MprisPlayer.cs | 15 +++++- .../Linux/DesktopEntriesTests.cs | 12 +++++ .../Linux/LaunchVariantTests.cs | 37 +++++++++++++ .../Linux/MprisPlayerTests.cs | 19 +++++++ 10 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/SystemMedia/Platform/Linux/LaunchVariant.cs create mode 100644 tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs diff --git a/AGENTS.md b/AGENTS.md index 2614a16..077ac9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `@`, read once from the bus owner's `/proc//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 diff --git a/README.md b/README.md index e73682e..31a3860 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/SystemMedia/Platform/Linux/DesktopEntries.cs b/src/SystemMedia/Platform/Linux/DesktopEntries.cs index 61e7b52..b07d3b4 100644 --- a/src/SystemMedia/Platform/Linux/DesktopEntries.cs +++ b/src/SystemMedia/Platform/Linux/DesktopEntries.cs @@ -50,9 +50,12 @@ private static bool IsFileName(string name) => } } - // Desktop actions carry Icon= keys of their own. - internal static string? IconNameOf(IEnumerable desktopFileLines) + internal string? NameOf(string desktopEntry) => IsFileName(desktopEntry) ? ValueOf(desktopEntry, "Name") : null; + + // Desktop actions carry keys of their own. + internal static string? ValueOf(IEnumerable desktopFileLines, string key) { + var prefix = key + "="; var inEntry = false; foreach (var raw in desktopFileLines) { @@ -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(); } } @@ -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; @@ -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) @@ -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) { diff --git a/src/SystemMedia/Platform/Linux/LaunchVariant.cs b/src/SystemMedia/Platform/Linux/LaunchVariant.cs new file mode 100644 index 0000000..57ac57e --- /dev/null +++ b/src/SystemMedia/Platform/Linux/LaunchVariant.cs @@ -0,0 +1,52 @@ +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 +{ + internal static string? Of(IReadOnlyList 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 } && !string.Equals(variant, appId, StringComparison.OrdinalIgnoreCase) ? variant : null; + } + + internal static IReadOnlyList 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 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('/')); +} diff --git a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs index ff431e6..efc5186 100644 --- a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs +++ b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs @@ -348,14 +348,31 @@ private async Task PollAsync() Replace(current); } - private static async Task ReadRootAsync(MprisBus bus, string busName) + private async Task 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()); + 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 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 current) diff --git a/src/SystemMedia/Platform/Linux/MprisBus.cs b/src/SystemMedia/Platform/Linux/MprisBus.cs index 031391e..94c6ec7 100644 --- a/src/SystemMedia/Platform/Linux/MprisBus.cs +++ b/src/SystemMedia/Platform/Linux/MprisBus.cs @@ -45,6 +45,14 @@ internal async Task> ListPlayersAsync() => null); } + internal Task 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(); diff --git a/src/SystemMedia/Platform/Linux/MprisPlayer.cs b/src/SystemMedia/Platform/Linux/MprisPlayer.cs index a3f8672..d9ae746 100644 --- a/src/SystemMedia/Platform/Linux/MprisPlayer.cs +++ b/src/SystemMedia/Platform/Linux/MprisPlayer.cs @@ -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); @@ -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"); diff --git a/tests/SystemMedia.Tests/Linux/DesktopEntriesTests.cs b/tests/SystemMedia.Tests/Linux/DesktopEntriesTests.cs index 3af3531..8654a39 100644 --- a/tests/SystemMedia.Tests/Linux/DesktopEntriesTests.cs +++ b/tests/SystemMedia.Tests/Linux/DesktopEntriesTests.cs @@ -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() { diff --git a/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs b/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs new file mode 100644 index 0000000..4bc04c1 --- /dev/null +++ b/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs @@ -0,0 +1,37 @@ +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); + + private static string? Variant(params string[] arguments) => LaunchVariant.Of(arguments, "firefox"); +} diff --git a/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs b/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs index e1111c2..02387b7 100644 --- a/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs +++ b/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs @@ -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()); + + 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() { From 00bfbe97138e9db237fc47eeaeed9005e16f2477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Thu, 8 Oct 2026 12:11:09 +0200 Subject: [PATCH 2/2] fix: ignore oversized or unprintable launch variants --- src/SystemMedia/Platform/Linux/LaunchVariant.cs | 6 +++++- tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/SystemMedia/Platform/Linux/LaunchVariant.cs b/src/SystemMedia/Platform/Linux/LaunchVariant.cs index 57ac57e..dd5cc13 100644 --- a/src/SystemMedia/Platform/Linux/LaunchVariant.cs +++ b/src/SystemMedia/Platform/Linux/LaunchVariant.cs @@ -3,11 +3,15 @@ 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 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 } && !string.Equals(variant, appId, StringComparison.OrdinalIgnoreCase) ? variant : null; + return variant is { Length: > 0 and <= MaxLength } && !variant.Any(char.IsControl) && + !string.Equals(variant, appId, StringComparison.OrdinalIgnoreCase) ? variant : null; } internal static IReadOnlyList ReadArguments(uint processId) diff --git a/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs b/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs index 4bc04c1..74611af 100644 --- a/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs +++ b/tests/SystemMedia.Tests/Linux/LaunchVariantTests.cs @@ -33,5 +33,13 @@ public void A_plain_launch_is_no_variant() 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"); }