From bfd0cfee3bc763d92b9845865542d8b5920f1cdd Mon Sep 17 00:00:00 2001 From: CristianMz21 <205552813+CristianMz21@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:07:44 -0500 Subject: [PATCH 1/2] fix(hyprland): detect dispatch protocol via configProvider Waybar decided between the legacy and the Lua dispatch protocol from the Hyprland version alone, assuming >= 0.54 always means Lua. Hyprland does not work that way: it only loads the Lua config manager when the config file name ends in ".lua", so an instance running a traditional hyprland.conf still speaks the legacy protocol no matter its version. Those users got Lua-formatted dispatches, Hyprland answered "Invalid dispatcher", and clicking a workspace button did nothing. Hyprland already reports which manager it loaded, in the "systeminfo" reply: configProvider: lua Read that instead. The field landed together with the Lua config manager in 0.55, so its absence means the instance predates Lua support entirely and necessarily speaks the legacy protocol, which makes the field sufficient on its own and lets the version heuristic go away. Dropping that heuristic also fixes 0.54, which it misread: Lua config support only arrived in 0.55, so ">= 0.54" claimed Lua on a release that has no Lua config manager at all. The query is read-only, so detection keeps none of the side effects that motivated replacing the earlier dispatch-based probe. The parsing lives in a pure parseConfigProvider() helper so it can be covered without a live compositor. Tests exercise both managers, a realistic full systeminfo reply, the absent field, formatting variations (tab and multi-space separators, CRLF, no trailing newline, empty value), and the socket failure path. Fixes #5198 --- include/modules/hyprland/backend.hpp | 11 +++- src/modules/hyprland/backend.cpp | 82 ++++++++++++++----------- test/hyprland/backend.cpp | 89 ++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 36 deletions(-) diff --git a/include/modules/hyprland/backend.hpp b/include/modules/hyprland/backend.hpp index 7c6369da7..a641419f0 100644 --- a/include/modules/hyprland/backend.hpp +++ b/include/modules/hyprland/backend.hpp @@ -47,9 +47,18 @@ class IPC { static std::filesystem::path socketFolder_; /// Detect whether the running Hyprland uses the Lua-based IPC protocol. - /// Returns true for Hyprland >= 0.54 (Lua config), false for older versions. + /// Resolved once from the config manager it reports, then cached. static bool isLuaProtocol(); + /// Ask Hyprland which config manager it loaded, via the "systeminfo" reply. + /// Returns nullopt when the query fails or the field is not reported. + static std::optional luaProtocolFromSystemInfo(); + + /// Extract the "configProvider:" field from a "systeminfo" reply. + /// Returns true for the Lua manager, false for anything else, and nullopt + /// when the field is absent, as on versions predating the Lua config manager. + static std::optional parseConfigProvider(const std::string& systemInfo); + static std::optional s_luaProtocolDetected_; // cached detection result private: diff --git a/src/modules/hyprland/backend.cpp b/src/modules/hyprland/backend.cpp index 891bed1a4..249ee7976 100644 --- a/src/modules/hyprland/backend.cpp +++ b/src/modules/hyprland/backend.cpp @@ -10,11 +10,13 @@ #include #include +#include #include #include #include #include #include +#include #include "util/scoped_fd.hpp" @@ -292,48 +294,58 @@ Json::Value IPC::getSocket1JsonReply(const std::string& rq) { return parser_.parse(reply); } -bool IPC::isLuaProtocol() { - if (s_luaProtocolDetected_.has_value()) { - return *s_luaProtocolDetected_; +std::optional IPC::parseConfigProvider(const std::string& systemInfo) { + // Hyprland reports which config manager it actually loaded in "systeminfo", + // as a "configProvider: lua" / "configProvider: legacy" line. That is the + // authoritative signal: the version alone is not enough, because Hyprland + // only uses the Lua manager when the config file name ends in ".lua", so a + // >= 0.54 instance started with a traditional hyprland.conf still speaks the + // legacy dispatch protocol. + static constexpr std::string_view key = "configProvider:"; + + const size_t keyPos = systemInfo.find(key); + if (keyPos == std::string::npos) { + return std::nullopt; } - // Detect the Lua-based dispatch protocol (Hyprland >= 0.54) via the read-only - // "version" query. This MUST have no side effects: an earlier probe issued a real - // "dispatch workspace __waybar_probe__", which on Hyprland < 0.54 actually switched - // the user to a junk workspace named __waybar_probe__ on the first click/scroll. - bool luaProto = false; - try { - util::JsonParser parser; - const Json::Value ver = parser.parse(getSocket1Reply("j/version")); - - // Prefer the numeric "version" field ("0.54.0"); fall back to the "tag" field - // ("v0.54.0" or "v0.54.0-16-gdeadbee"), which is present on all releases. - std::string versionStr = ver["version"].asString(); - if (versionStr.empty()) { - versionStr = ver["tag"].asString(); - } + size_t valuePos = systemInfo.find_first_not_of(" \t", keyPos + key.size()); + if (valuePos == std::string::npos) { + return false; + } - const size_t firstDigit = versionStr.find_first_of("0123456789"); - if (firstDigit != std::string::npos) { - // std::stoi parses the leading integer and stops at the first non-digit, so it - // tolerates the trailing ".patch-commits-ghash" suffix on the tag. - const int major = std::stoi(versionStr.substr(firstDigit)); - int minor = 0; - const size_t dot = versionStr.find('.', firstDigit); - if (dot != std::string::npos && dot + 1 < versionStr.size()) { - minor = std::stoi(versionStr.substr(dot + 1)); - } - luaProto = major > 0 || (major == 0 && minor >= 54); - } else { - spdlog::warn("Hyprland IPC: could not parse version '{}', assuming legacy protocol", - versionStr); - } + const size_t end = systemInfo.find_first_of("\r\n", valuePos); + std::string provider = systemInfo.substr(valuePos, end - valuePos); + while (!provider.empty() && std::isspace(static_cast(provider.back()))) { + provider.pop_back(); + } + + spdlog::debug("Hyprland IPC: systeminfo reports configProvider '{}'", provider); + return provider == "lua"; +} + +std::optional IPC::luaProtocolFromSystemInfo() { + try { + return parseConfigProvider(getSocket1Reply("systeminfo")); } catch (const std::exception& e) { - spdlog::warn("Hyprland IPC: version detection failed ({}), assuming legacy protocol", e.what()); + spdlog::warn("Hyprland IPC: could not read configProvider from systeminfo ({})", e.what()); + return std::nullopt; + } +} + +bool IPC::isLuaProtocol() { + if (s_luaProtocolDetected_.has_value()) { + return *s_luaProtocolDetected_; } + // configProvider landed together with the Lua config manager in 0.55, so its + // absence means the instance predates Lua support entirely and necessarily + // speaks the legacy protocol. That makes the field sufficient on its own, and + // it is read-only, so detection has none of the side effects of an actual + // dispatch probe. + const bool luaProto = luaProtocolFromSystemInfo().value_or(false); + if (luaProto) { - spdlog::info("Hyprland IPC: detected Lua-based dispatch protocol (Hyprland >= 0.54)"); + spdlog::info("Hyprland IPC: detected Lua-based dispatch protocol"); } else { spdlog::info("Hyprland IPC: detected legacy dispatch protocol"); } diff --git a/test/hyprland/backend.cpp b/test/hyprland/backend.cpp index f2e10daea..db7184224 100644 --- a/test/hyprland/backend.cpp +++ b/test/hyprland/backend.cpp @@ -4,6 +4,9 @@ #include #endif +#include +#include +#include #include #include "modules/hyprland/backend.hpp" @@ -19,8 +22,27 @@ class IPCTestHelper : public hyprland::IPC { static void setLuaProtocolDetected(bool value) { s_luaProtocolDetected_ = value; } using hyprland::IPC::buildLuaDispatch; using hyprland::IPC::isLuaProtocol; + using hyprland::IPC::luaProtocolFromSystemInfo; + using hyprland::IPC::parseConfigProvider; }; +// Trimmed but otherwise verbatim "systeminfo" reply from Hyprland 0.56.1. +constexpr auto kSystemInfoLua = R"( +Hyprland 0.56.1 built from branch v0.56.1 at commit deadbeef clean. +Date: Mon Jul 27 16:33:49 2026 +Tag: v0.56.1, commits: 7643 + +Libraries: +Hyprutils: built against 0.14.0, system has 0.14.0 + +os-release: Fedora Linux 43 + +plugins: + no plugins loaded + +configProvider: lua +)"; + std::size_t countOpenFds() { #if defined(__linux__) std::size_t count = 0; @@ -192,6 +214,73 @@ TEST_CASE("dispatch throws when Hyprland is not running", "[dispatch]") { CHECK_THROWS(hyprland::IPC::dispatch("workspace", "1")); } +TEST_CASE("parseConfigProvider reads the config manager Hyprland loaded", "[parseConfigProvider]") { + SECTION("realistic systeminfo reply reports the Lua manager") { + REQUIRE(IPCTestHelper::parseConfigProvider(kSystemInfoLua) == true); + } + + SECTION("lua") { REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\n") == true); } + + SECTION("legacy") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: legacy\n") == false); + } + + // A >= 0.54 instance started with a traditional hyprland.conf keeps the + // legacy parser, which is exactly the case the version heuristic got wrong. + SECTION("legacy manager inside a full reply") { + std::string info{kSystemInfoLua}; + info.replace(info.find("configProvider: lua"), std::strlen("configProvider: lua"), + "configProvider: legacy"); + REQUIRE(IPCTestHelper::parseConfigProvider(info) == false); + } + + SECTION("an unrecognised manager is not treated as Lua") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: something-else\n") == false); + } +} + +TEST_CASE("parseConfigProvider tolerates formatting variations", "[parseConfigProvider]") { + SECTION("tab separator") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider:\tlua\n") == true); + } + + SECTION("extra spaces") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\n") == true); + } + + SECTION("CRLF line ending") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\r\n") == true); + } + + SECTION("last line without a trailing newline") { + REQUIRE(IPCTestHelper::parseConfigProvider("plugins:\nconfigProvider: lua") == true); + } + + SECTION("empty value is not Lua") { + REQUIRE(IPCTestHelper::parseConfigProvider("configProvider:\n") == false); + } +} + +TEST_CASE("parseConfigProvider returns nullopt when the field is absent", "[parseConfigProvider]") { + // Hyprland versions predating the Lua config manager do not report the field; + // callers must fall back to version detection rather than assume legacy here. + SECTION("older systeminfo reply") { + REQUIRE(IPCTestHelper::parseConfigProvider("Hyprland 0.41.2\nTag: v0.41.2\n") == std::nullopt); + } + + SECTION("empty reply") { REQUIRE(IPCTestHelper::parseConfigProvider("") == std::nullopt); } +} + +TEST_CASE("luaProtocolFromSystemInfo returns nullopt when Hyprland is not running", + "[luaProtocolFromSystemInfo]") { + // getSocket1Reply throws; detection must degrade to the version fallback + // instead of propagating and breaking the click. + unsetenv("HYPRLAND_INSTANCE_SIGNATURE"); + IPCTestHelper::resetSocketFolder(); + + REQUIRE(IPCTestHelper::luaProtocolFromSystemInfo() == std::nullopt); +} + TEST_CASE("isLuaProtocol uses cached value and avoids socket call", "[isLuaProtocol]") { unsetenv("HYPRLAND_INSTANCE_SIGNATURE"); From 92742e46b1e512951a4bbdf1e4fd052ce109f1b4 Mon Sep 17 00:00:00 2001 From: CristianMz21 <205552813+CristianMz21@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:46:38 -0500 Subject: [PATCH 2/2] refactor(hyprland): simplify configProvider detection per review Address review feedback from Bart97: - Collapse parseConfigProvider + luaProtocolFromSystemInfo into a single isLuaConfigProvider, whose name states the boolean it answers. Two functions instead of three; the pure helper stays separate from the socket call only because CI has no compositor, so the parsing paths (absent field, legacy value, formatting) are only coverable through it. - Drop the optional: since the version fallback was removed, an absent field and a non-lua value both mean legacy, so the tri-state answered nothing the caller could act on. - Handle the end-of-reply case with an explicit npos branch instead of relying on substr clamping. - Reuse trim() from util/string.hpp instead of a hand-rolled rtrim loop; it also covers the leading separator whitespace and trailing CR. --- include/modules/hyprland/backend.hpp | 12 ++---- src/modules/hyprland/backend.cpp | 50 ++++++++++-------------- test/hyprland/backend.cpp | 58 ++++++++++++++-------------- 3 files changed, 53 insertions(+), 67 deletions(-) diff --git a/include/modules/hyprland/backend.hpp b/include/modules/hyprland/backend.hpp index a641419f0..e9b6a58b3 100644 --- a/include/modules/hyprland/backend.hpp +++ b/include/modules/hyprland/backend.hpp @@ -50,14 +50,10 @@ class IPC { /// Resolved once from the config manager it reports, then cached. static bool isLuaProtocol(); - /// Ask Hyprland which config manager it loaded, via the "systeminfo" reply. - /// Returns nullopt when the query fails or the field is not reported. - static std::optional luaProtocolFromSystemInfo(); - - /// Extract the "configProvider:" field from a "systeminfo" reply. - /// Returns true for the Lua manager, false for anything else, and nullopt - /// when the field is absent, as on versions predating the Lua config manager. - static std::optional parseConfigProvider(const std::string& systemInfo); + /// Whether a "systeminfo" reply reports the Lua config manager. An absent + /// "configProvider:" field means the instance predates the Lua config + /// manager (< 0.55) and therefore speaks the legacy protocol. + static bool isLuaConfigProvider(const std::string& systemInfo); static std::optional s_luaProtocolDetected_; // cached detection result diff --git a/src/modules/hyprland/backend.cpp b/src/modules/hyprland/backend.cpp index 249ee7976..7abef02ac 100644 --- a/src/modules/hyprland/backend.cpp +++ b/src/modules/hyprland/backend.cpp @@ -10,7 +10,6 @@ #include #include -#include #include #include #include @@ -19,6 +18,7 @@ #include #include "util/scoped_fd.hpp" +#include "util/string.hpp" namespace waybar::modules::hyprland { @@ -294,42 +294,29 @@ Json::Value IPC::getSocket1JsonReply(const std::string& rq) { return parser_.parse(reply); } -std::optional IPC::parseConfigProvider(const std::string& systemInfo) { +bool IPC::isLuaConfigProvider(const std::string& systemInfo) { // Hyprland reports which config manager it actually loaded in "systeminfo", // as a "configProvider: lua" / "configProvider: legacy" line. That is the // authoritative signal: the version alone is not enough, because Hyprland // only uses the Lua manager when the config file name ends in ".lua", so a // >= 0.54 instance started with a traditional hyprland.conf still speaks the - // legacy dispatch protocol. + // legacy dispatch protocol. The field landed together with the Lua config + // manager in 0.55, so its absence means the instance predates Lua support + // entirely and necessarily speaks the legacy protocol. static constexpr std::string_view key = "configProvider:"; const size_t keyPos = systemInfo.find(key); if (keyPos == std::string::npos) { - return std::nullopt; - } - - size_t valuePos = systemInfo.find_first_not_of(" \t", keyPos + key.size()); - if (valuePos == std::string::npos) { return false; } - const size_t end = systemInfo.find_first_of("\r\n", valuePos); - std::string provider = systemInfo.substr(valuePos, end - valuePos); - while (!provider.empty() && std::isspace(static_cast(provider.back()))) { - provider.pop_back(); - } + const size_t valuePos = keyPos + key.size(); + const size_t lineEnd = systemInfo.find('\n', valuePos); + const std::string value = lineEnd == std::string::npos + ? systemInfo.substr(valuePos) + : systemInfo.substr(valuePos, lineEnd - valuePos); - spdlog::debug("Hyprland IPC: systeminfo reports configProvider '{}'", provider); - return provider == "lua"; -} - -std::optional IPC::luaProtocolFromSystemInfo() { - try { - return parseConfigProvider(getSocket1Reply("systeminfo")); - } catch (const std::exception& e) { - spdlog::warn("Hyprland IPC: could not read configProvider from systeminfo ({})", e.what()); - return std::nullopt; - } + return trim(value) == "lua"; } bool IPC::isLuaProtocol() { @@ -337,12 +324,15 @@ bool IPC::isLuaProtocol() { return *s_luaProtocolDetected_; } - // configProvider landed together with the Lua config manager in 0.55, so its - // absence means the instance predates Lua support entirely and necessarily - // speaks the legacy protocol. That makes the field sufficient on its own, and - // it is read-only, so detection has none of the side effects of an actual - // dispatch probe. - const bool luaProto = luaProtocolFromSystemInfo().value_or(false); + // The query is read-only, so detection has none of the side effects of an + // actual dispatch probe. + bool luaProto = false; + try { + luaProto = isLuaConfigProvider(getSocket1Reply("systeminfo")); + } catch (const std::exception& e) { + spdlog::warn("Hyprland IPC: could not read systeminfo ({}), assuming legacy protocol", + e.what()); + } if (luaProto) { spdlog::info("Hyprland IPC: detected Lua-based dispatch protocol"); diff --git a/test/hyprland/backend.cpp b/test/hyprland/backend.cpp index db7184224..3fb2a59b9 100644 --- a/test/hyprland/backend.cpp +++ b/test/hyprland/backend.cpp @@ -21,9 +21,8 @@ class IPCTestHelper : public hyprland::IPC { static void resetLuaProtocolDetection() { s_luaProtocolDetected_.reset(); } static void setLuaProtocolDetected(bool value) { s_luaProtocolDetected_ = value; } using hyprland::IPC::buildLuaDispatch; + using hyprland::IPC::isLuaConfigProvider; using hyprland::IPC::isLuaProtocol; - using hyprland::IPC::luaProtocolFromSystemInfo; - using hyprland::IPC::parseConfigProvider; }; // Trimmed but otherwise verbatim "systeminfo" reply from Hyprland 0.56.1. @@ -214,15 +213,15 @@ TEST_CASE("dispatch throws when Hyprland is not running", "[dispatch]") { CHECK_THROWS(hyprland::IPC::dispatch("workspace", "1")); } -TEST_CASE("parseConfigProvider reads the config manager Hyprland loaded", "[parseConfigProvider]") { +TEST_CASE("isLuaConfigProvider reads the config manager Hyprland loaded", "[isLuaConfigProvider]") { SECTION("realistic systeminfo reply reports the Lua manager") { - REQUIRE(IPCTestHelper::parseConfigProvider(kSystemInfoLua) == true); + REQUIRE(IPCTestHelper::isLuaConfigProvider(kSystemInfoLua) == true); } - SECTION("lua") { REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\n") == true); } + SECTION("lua") { REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\n") == true); } SECTION("legacy") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: legacy\n") == false); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: legacy\n") == false); } // A >= 0.54 instance started with a traditional hyprland.conf keeps the @@ -231,54 +230,55 @@ TEST_CASE("parseConfigProvider reads the config manager Hyprland loaded", "[pars std::string info{kSystemInfoLua}; info.replace(info.find("configProvider: lua"), std::strlen("configProvider: lua"), "configProvider: legacy"); - REQUIRE(IPCTestHelper::parseConfigProvider(info) == false); + REQUIRE(IPCTestHelper::isLuaConfigProvider(info) == false); } SECTION("an unrecognised manager is not treated as Lua") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: something-else\n") == false); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: something-else\n") == false); } + + // The field ships together with the Lua config manager (0.55), so replies + // without it come from instances that only speak the legacy protocol. + SECTION("absent field means a pre-Lua instance, hence legacy") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("Hyprland 0.41.2\nTag: v0.41.2\n") == false); + } + + SECTION("empty reply") { REQUIRE(IPCTestHelper::isLuaConfigProvider("") == false); } } -TEST_CASE("parseConfigProvider tolerates formatting variations", "[parseConfigProvider]") { +TEST_CASE("isLuaConfigProvider tolerates formatting variations", "[isLuaConfigProvider]") { SECTION("tab separator") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider:\tlua\n") == true); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider:\tlua\n") == true); } SECTION("extra spaces") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\n") == true); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\n") == true); } SECTION("CRLF line ending") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider: lua\r\n") == true); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\r\n") == true); } SECTION("last line without a trailing newline") { - REQUIRE(IPCTestHelper::parseConfigProvider("plugins:\nconfigProvider: lua") == true); + REQUIRE(IPCTestHelper::isLuaConfigProvider("plugins:\nconfigProvider: lua") == true); } SECTION("empty value is not Lua") { - REQUIRE(IPCTestHelper::parseConfigProvider("configProvider:\n") == false); - } -} - -TEST_CASE("parseConfigProvider returns nullopt when the field is absent", "[parseConfigProvider]") { - // Hyprland versions predating the Lua config manager do not report the field; - // callers must fall back to version detection rather than assume legacy here. - SECTION("older systeminfo reply") { - REQUIRE(IPCTestHelper::parseConfigProvider("Hyprland 0.41.2\nTag: v0.41.2\n") == std::nullopt); + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider:\n") == false); } - - SECTION("empty reply") { REQUIRE(IPCTestHelper::parseConfigProvider("") == std::nullopt); } } -TEST_CASE("luaProtocolFromSystemInfo returns nullopt when Hyprland is not running", - "[luaProtocolFromSystemInfo]") { - // getSocket1Reply throws; detection must degrade to the version fallback - // instead of propagating and breaking the click. +TEST_CASE("isLuaProtocol assumes legacy when Hyprland is not reachable", "[isLuaProtocol]") { + // getSocket1Reply throws; detection must degrade to legacy instead of + // propagating and breaking the click. unsetenv("HYPRLAND_INSTANCE_SIGNATURE"); IPCTestHelper::resetSocketFolder(); + IPCTestHelper::resetLuaProtocolDetection(); - REQUIRE(IPCTestHelper::luaProtocolFromSystemInfo() == std::nullopt); + REQUIRE(IPCTestHelper::isLuaProtocol() == false); + + // Cleanup: drop the cached result so other tests aren't affected + IPCTestHelper::resetLuaProtocolDetection(); } TEST_CASE("isLuaProtocol uses cached value and avoids socket call",