diff --git a/include/modules/hyprland/backend.hpp b/include/modules/hyprland/backend.hpp index 7c6369da7..e9b6a58b3 100644 --- a/include/modules/hyprland/backend.hpp +++ b/include/modules/hyprland/backend.hpp @@ -47,9 +47,14 @@ 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(); + /// 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 private: diff --git a/src/modules/hyprland/backend.cpp b/src/modules/hyprland/backend.cpp index 891bed1a4..4e89334bd 100644 --- a/src/modules/hyprland/backend.cpp +++ b/src/modules/hyprland/backend.cpp @@ -15,8 +15,10 @@ #include #include #include +#include #include "util/scoped_fd.hpp" +#include "util/string.hpp" namespace waybar::modules::hyprland { @@ -292,48 +294,48 @@ Json::Value IPC::getSocket1JsonReply(const std::string& rq) { return parser_.parse(reply); } +bool IPC::isLuaConfigProvider(const std::string& systemInfo) { + // Hyprland reports which config manager it actually loaded in "systeminfo", + // as a "configProvider: lua" / "configProvider: hyprlang" 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. 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 false; + } + + 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); + + return trim(value) == "lua"; +} + bool IPC::isLuaProtocol() { if (s_luaProtocolDetected_.has_value()) { return *s_luaProtocolDetected_; } - // 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. + // The query is read-only, so detection has none of the side effects of an + // actual dispatch probe. 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(); - } - - 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); - } + luaProto = isLuaConfigProvider(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 systeminfo ({}), assuming legacy protocol", + e.what()); } 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..4964634f6 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" @@ -18,9 +21,27 @@ 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; }; +// 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 +213,76 @@ TEST_CASE("dispatch throws when Hyprland is not running", "[dispatch]") { CHECK_THROWS(hyprland::IPC::dispatch("workspace", "1")); } +TEST_CASE("isLuaConfigProvider reads the config manager Hyprland loaded", "[isLuaConfigProvider]") { + SECTION("realistic systeminfo reply reports the Lua manager") { + REQUIRE(IPCTestHelper::isLuaConfigProvider(kSystemInfoLua) == true); + } + + SECTION("lua") { REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\n") == true); } + + // "hyprlang" is what Hyprland actually emits for the legacy manager, per + // Config::typeToString in src/config/ConfigManager.cpp. + SECTION("hyprlang") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: hyprlang\n") == false); + } + + // A >= 0.55 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: hyprlang"); + REQUIRE(IPCTestHelper::isLuaConfigProvider(info) == false); + } + + SECTION("an unrecognised manager is not treated as Lua") { + 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("isLuaConfigProvider tolerates formatting variations", "[isLuaConfigProvider]") { + SECTION("tab separator") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider:\tlua\n") == true); + } + + SECTION("extra spaces") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\n") == true); + } + + SECTION("CRLF line ending") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider: lua\r\n") == true); + } + + SECTION("last line without a trailing newline") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("plugins:\nconfigProvider: lua") == true); + } + + SECTION("empty value is not Lua") { + REQUIRE(IPCTestHelper::isLuaConfigProvider("configProvider:\n") == false); + } +} + +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::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", "[isLuaProtocol]") { unsetenv("HYPRLAND_INSTANCE_SIGNATURE");