From 43d7fab48cce8168ae9728a7bde1fb5655ca3c5d Mon Sep 17 00:00:00 2001 From: EinBaum Date: Fri, 28 Aug 2026 10:52:44 +0200 Subject: [PATCH] hyprland: drop legacy dispatch protocol IPC::dispatch always sends hl.dsp.focus / toggle_special. Clicking workspace buttons no longer uses "dispatch workspace N". --- include/modules/hyprland/backend.hpp | 15 +-- man/waybar-hyprland-workspaces.5.scd | 4 +- src/modules/hyprland/backend.cpp | 104 +++++--------------- test/config.cpp | 6 +- test/config/hyprland-workspaces.json | 4 +- test/hyprland/backend.cpp | 142 +++------------------------ 6 files changed, 44 insertions(+), 231 deletions(-) diff --git a/include/modules/hyprland/backend.hpp b/include/modules/hyprland/backend.hpp index e9b6a58b3c..c7c53bd420 100644 --- a/include/modules/hyprland/backend.hpp +++ b/include/modules/hyprland/backend.hpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -36,8 +35,7 @@ class IPC { Json::Value getSocket1JsonReply(const std::string& rq); static std::filesystem::path getSocketFolder(const char* instanceSig); - /// Dispatch a Hyprland command. Automatically uses the correct protocol - /// (legacy text or Lua-based) depending on the running Hyprland version. + /// Dispatch a Hyprland command using the Lua hl.dsp protocol. static std::string dispatch(const std::string& dispatcher, const std::string& arg); /// Build a Lua-format dispatch command string. @@ -46,17 +44,6 @@ class IPC { protected: static std::filesystem::path socketFolder_; - /// Detect whether the running Hyprland uses the Lua-based IPC protocol. - /// 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: void socketListener(); void parseIPC(const std::string&); diff --git a/man/waybar-hyprland-workspaces.5.scd b/man/waybar-hyprland-workspaces.5.scd index 3010fb4558..8b29c29ba4 100644 --- a/man/waybar-hyprland-workspaces.5.scd +++ b/man/waybar-hyprland-workspaces.5.scd @@ -140,7 +140,7 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. typeof: object ++ default: empty ++ Lists workspaces that should always be shown, even when they do not exist. Keys are workspace names and values are arrays of output names on which the workspace should be shown (an empty array means all outputs). See the examples below. ++ - Note: for persistent workspaces to actually work you must also declare them in your Hyprland config, e.g. *workspace = 1, monitor:eDP-1, persistent:true*. + Note: for persistent workspaces to actually work you must also declare them in your Hyprland config, e.g. *hl.workspace_rule({ workspace = "1", monitor = "eDP-1", persistent = true })*. *all-outputs*: ++ typeof: bool ++ @@ -162,7 +162,7 @@ This setting is ignored if *workspace-taskbar.enable* is set to true. default: false ++ If set to true, open the workspace on the current monitor when clicking on a workspace button. Otherwise, the workspace will open on the monitor where it was previously assigned. - Analog to using `focusworkspaceoncurrentmonitor` dispatcher instead of `workspace` in Hyprland. + Analog to using `hl.dsp.focus({ workspace = "N", on_current_monitor = true })` instead of `hl.dsp.focus({ workspace = "N" })`. *unique-icons*: ++ typeof: bool ++ diff --git a/src/modules/hyprland/backend.cpp b/src/modules/hyprland/backend.cpp index 4e89334bd0..b8e57f93f8 100644 --- a/src/modules/hyprland/backend.cpp +++ b/src/modules/hyprland/backend.cpp @@ -13,17 +13,29 @@ #include #include #include -#include #include -#include #include "util/scoped_fd.hpp" -#include "util/string.hpp" + +namespace { + +std::string luaEscape(const std::string& value) { + std::string escaped; + escaped.reserve(value.size()); + for (char c : value) { + if (c == '\\' || c == '"') { + escaped.push_back('\\'); + } + escaped.push_back(c); + } + return escaped; +} + +} // namespace namespace waybar::modules::hyprland { std::filesystem::path IPC::socketFolder_; -std::optional IPC::s_luaProtocolDetected_; std::filesystem::path IPC::getSocketFolder(const char* instanceSig) { static std::mutex folderMutex; @@ -294,98 +306,28 @@ 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_; - } - - // 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"); - } else { - spdlog::info("Hyprland IPC: detected legacy dispatch protocol"); - } - - s_luaProtocolDetected_ = luaProto; - return luaProto; -} - std::string IPC::buildLuaDispatch(const std::string& dispatcher, const std::string& arg) { - // Map old-style dispatchers to the new Lua hl.dsp API. - // - // Old format: dispatch workspace 1 - // New format: /dispatch hl.dsp.focus({ workspace = "1" }) - // - // Old format: dispatch focusworkspaceoncurrentmonitor 2 - // New format: /dispatch hl.dsp.focus({ workspace = "2", on_current_monitor = true }) - // - // Old format: dispatch togglespecialworkspace name - // New format: /dispatch hl.dsp.workspace.toggle_special("name") - + const std::string escaped = luaEscape(arg); if (dispatcher == "workspace") { - return "/dispatch hl.dsp.focus({ workspace = \"" + arg + "\" })"; + return "/dispatch hl.dsp.focus({ workspace = \"" + escaped + "\" })"; } if (dispatcher == "focusworkspaceoncurrentmonitor") { - return "/dispatch hl.dsp.focus({ workspace = \"" + arg + "\", on_current_monitor = true })"; + return "/dispatch hl.dsp.focus({ workspace = \"" + escaped + "\", on_current_monitor = true })"; } if (dispatcher == "togglespecialworkspace") { if (arg.empty()) { return "/dispatch hl.dsp.workspace.toggle_special()"; } - return "/dispatch hl.dsp.workspace.toggle_special(\"" + arg + "\")"; + return "/dispatch hl.dsp.workspace.toggle_special(\"" + escaped + "\")"; } - // Fallback for any other dispatcher: try the old format wrapped in dispatch(). - // This may not work for all dispatchers, but it's a reasonable default. - spdlog::warn("Hyprland IPC: unknown dispatcher '{}' in Lua mode, attempting generic format", + spdlog::warn("Hyprland IPC: unknown dispatcher '{}', attempting generic hl.dsp format", dispatcher); - return "/dispatch hl.dsp." + dispatcher + "(\"" + arg + "\")"; + return "/dispatch hl.dsp." + dispatcher + "(\"" + escaped + "\")"; } std::string IPC::dispatch(const std::string& dispatcher, const std::string& arg) { - if (isLuaProtocol()) { - return getSocket1Reply(buildLuaDispatch(dispatcher, arg)); - } - // Legacy format: "dispatch " - std::string cmd = "dispatch " + dispatcher; - if (!arg.empty()) { - cmd += " " + arg; - } - return getSocket1Reply(cmd); + return getSocket1Reply(buildLuaDispatch(dispatcher, arg)); } } // namespace waybar::modules::hyprland diff --git a/test/config.cpp b/test/config.cpp index 27e84b9bb6..c7923133d1 100644 --- a/test/config.cpp +++ b/test/config.cpp @@ -169,8 +169,10 @@ TEST_CASE("Load Hyprland Workspaces bar config", "[config]") { REQUIRE(hyprland["unique-icons"].asBool() == true); REQUIRE(hyprland["format"].asString() == "{icon} {windows}"); REQUIRE(hyprland["format-window-separator"].asString() == " "); - REQUIRE(hyprland["on-scroll-down"].asString() == "hyprctl dispatch workspace e-1"); - REQUIRE(hyprland["on-scroll-up"].asString() == "hyprctl dispatch workspace e+1"); + REQUIRE(hyprland["on-scroll-down"].asString() == + "hyprctl dispatch 'hl.dsp.focus({ workspace = \"e-1\" })'"); + REQUIRE(hyprland["on-scroll-up"].asString() == + "hyprctl dispatch 'hl.dsp.focus({ workspace = \"e+1\" })'"); REQUIRE(hyprland["show-special"].asBool() == true); REQUIRE(hyprland["window-rewrite-default"].asString() == ""); REQUIRE(hyprland["window-rewrite-separator"].asString() == " "); diff --git a/test/config/hyprland-workspaces.json b/test/config/hyprland-workspaces.json index 903e94f94f..4f5ff79db9 100644 --- a/test/config/hyprland-workspaces.json +++ b/test/config/hyprland-workspaces.json @@ -24,8 +24,8 @@ "persistent-workspaces": { "1": "HDMI-0" }, - "on-scroll-down": "hyprctl dispatch workspace e-1", - "on-scroll-up": "hyprctl dispatch workspace e+1", + "on-scroll-down": "hyprctl dispatch 'hl.dsp.focus({ workspace = \"e-1\" })'", + "on-scroll-up": "hyprctl dispatch 'hl.dsp.focus({ workspace = \"e+1\" })'", "window-rewrite": { "title": "" }, diff --git a/test/hyprland/backend.cpp b/test/hyprland/backend.cpp index 4964634f63..6cd89a4804 100644 --- a/test/hyprland/backend.cpp +++ b/test/hyprland/backend.cpp @@ -4,8 +4,6 @@ #include #endif -#include -#include #include #include @@ -18,30 +16,9 @@ namespace { class IPCTestHelper : public hyprland::IPC { public: static void resetSocketFolder() { socketFolder_.clear(); } - 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; @@ -177,129 +154,34 @@ TEST_CASE("buildLuaDispatch workspace", "[buildLuaDispatch]") { } TEST_CASE("buildLuaDispatch focusworkspaceoncurrentmonitor", "[buildLuaDispatch]") { - auto result = - IPCTestHelper::buildLuaDispatch("focusworkspaceoncurrentmonitor", "3"); - REQUIRE( - result == - "/dispatch hl.dsp.focus({ workspace = \"3\", on_current_monitor = true })"); + auto result = IPCTestHelper::buildLuaDispatch("focusworkspaceoncurrentmonitor", "3"); + REQUIRE(result == "/dispatch hl.dsp.focus({ workspace = \"3\", on_current_monitor = true })"); } TEST_CASE("buildLuaDispatch togglespecialworkspace", "[buildLuaDispatch]") { SECTION("with name") { - auto result = - IPCTestHelper::buildLuaDispatch("togglespecialworkspace", "scratchpad"); - REQUIRE(result == - "/dispatch hl.dsp.workspace.toggle_special(\"scratchpad\")"); + auto result = IPCTestHelper::buildLuaDispatch("togglespecialworkspace", "scratchpad"); + REQUIRE(result == "/dispatch hl.dsp.workspace.toggle_special(\"scratchpad\")"); } SECTION("empty arg") { - auto result = - IPCTestHelper::buildLuaDispatch("togglespecialworkspace", ""); + auto result = IPCTestHelper::buildLuaDispatch("togglespecialworkspace", ""); REQUIRE(result == "/dispatch hl.dsp.workspace.toggle_special()"); } } TEST_CASE("buildLuaDispatch unknown dispatcher fallback", "[buildLuaDispatch]") { - auto result = - IPCTestHelper::buildLuaDispatch("unknown_dispatcher", "some_arg"); - REQUIRE(result == - "/dispatch hl.dsp.unknown_dispatcher(\"some_arg\")"); -} - -TEST_CASE("dispatch throws when Hyprland is not running", "[dispatch]") { - unsetenv("HYPRLAND_INSTANCE_SIGNATURE"); - IPCTestHelper::resetSocketFolder(); - IPCTestHelper::resetLuaProtocolDetection(); - - 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); } + auto result = IPCTestHelper::buildLuaDispatch("unknown_dispatcher", "some_arg"); + REQUIRE(result == "/dispatch hl.dsp.unknown_dispatcher(\"some_arg\")"); } -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("buildLuaDispatch escapes quotes and backslashes", "[buildLuaDispatch]") { + auto result = IPCTestHelper::buildLuaDispatch("workspace", "name:foo\"bar\\baz"); + REQUIRE(result == R"(/dispatch hl.dsp.focus({ workspace = "name:foo\"bar\\baz" }))"); } -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]") { +TEST_CASE("dispatch throws when Hyprland is not running", "[dispatch]") { unsetenv("HYPRLAND_INSTANCE_SIGNATURE"); IPCTestHelper::resetSocketFolder(); - SECTION("cached false") { - IPCTestHelper::setLuaProtocolDetected(false); - // Should return false without throwing (no socket call needed) - REQUIRE(IPCTestHelper::isLuaProtocol() == false); - } - - SECTION("cached true") { - IPCTestHelper::setLuaProtocolDetected(true); - // Should return true without throwing (no socket call needed) - REQUIRE(IPCTestHelper::isLuaProtocol() == true); - } - - // Cleanup: reset detection so other tests aren't affected - IPCTestHelper::resetLuaProtocolDetection(); + CHECK_THROWS(hyprland::IPC::dispatch("workspace", "1")); }