Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 additions & 1 deletion include/modules/hyprland/backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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<bool> parseConfigProvider(const std::string& systemInfo);

@Bart97 Bart97 Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name parseConfigProvider suggests that the function returns a parsed config provider, whereas it actually answers the question "is the config provider Lua?". Since the return type is std::optional<bool> it would be better to use a name similar to isLuaProtocol, where the boolean states are clearly understandable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — renamed to isLuaConfigProvider in 92742e4, so the bool it returns is the question the name asks.


static std::optional<bool> s_luaProtocolDetected_; // cached detection result

private:
Expand Down
82 changes: 47 additions & 35 deletions src/modules/hyprland/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
#include <unistd.h>

#include <array>
#include <cctype>
#include <cerrno>
#include <cstring>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>

#include "util/scoped_fd.hpp"

Expand Down Expand Up @@ -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<bool> 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);

@Bart97 Bart97 Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a check for end == std::string::npos. This code will usually work (unless someone tries to build it on some weird platform), but I'm not sure if it's considered as a good pattern.

std::string::npos is defined as static constexpr size_type npos = size_type(-1);, where size_type is defined as an unsigned integer type. So npos actually equals to the maximum positive value of size_type.
This line relies on end being a huge number in such case, and valuePos will usually have a significantly lower value, so the result of the subtraction will still exceed the remaining length of the string.
Technically this is fine, because according to cppreference substr

Returns a substring [pos, pos + count). If the requested substring extends past the end of the string, i.e. the count is greater than size() - pos (e.g. if count == npos), the returned substring is [pos, size()).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made it an explicit branch in 92742e4: lineEnd == npos ? substr(valuePos) : substr(valuePos, lineEnd - valuePos). You are right that the clamping behaviour is guaranteed by the standard, but the explicit form reads as intent instead of as a coincidence that happens to be well-defined.

while (!provider.empty() && std::isspace(static_cast<unsigned char>(provider.back()))) {

@Bart97 Bart97 Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

include/util/string.hpp contains an rtrim function, would that be sufficient?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better than sufficient — switched to trim() from util/string.hpp in 92742e4. It also absorbs the leading separator whitespace and the trailing CR, which removed the hand-rolled find_first_not_of step and the pop_back loop entirely.

provider.pop_back();
}

spdlog::debug("Hyprland IPC: systeminfo reports configProvider '{}'", provider);
return provider == "lua";
}

std::optional<bool> 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() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure that this function needs to be split into 3? In this case I feel like it increases complexity and makes code harder to read. I suppose it makes it slightly easier to write UTs, but imo it's not a significant difference.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collapsed to two in 92742e4: isLuaProtocol (socket + cache + logging) and isLuaConfigProvider (pure parsing). I kept that one seam deliberately: CI has no compositor, so the parsing paths — absent field, legacy value, formatting variations — are only reachable through a function that takes the reply as a string. Folding it into isLuaProtocol would make those branches untestable rather than simpler. If you still prefer a single function I will fold it, but this is the trade-off as I see it.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth it to have those functions return an optional bool considering that in the end nullopt follows the same path as false?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and the history explains how it got there: the tri-state existed to route "field absent" to the version fallback. Once Mrpaoo's comment removed that fallback, nullopt and false converged on the same path and the optional answered nothing the caller could act on. Dropped in 92742e4 — plain bool, absent field documented as "pre-Lua instance, hence legacy".


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");
}
Expand Down
89 changes: 89 additions & 0 deletions test/hyprland/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
#include <catch2/catch.hpp>
#endif

#include <cstring>
#include <optional>
#include <string>
#include <system_error>

#include "modules/hyprland/backend.hpp"
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Loading