diff --git a/include/client.hpp b/include/client.hpp index 9ec29ef3..641ee6a7 100644 --- a/include/client.hpp +++ b/include/client.hpp @@ -7,6 +7,7 @@ #include "bar.hpp" #include "config.hpp" +#include "util/css_reload_helper.hpp" #include "util/portal.hpp" struct zwlr_layer_shell_v1; @@ -55,6 +56,8 @@ class Client { Glib::RefPtr css_provider_; std::unique_ptr portal; std::list outputs_; + std::unique_ptr m_cssReloadHelper; + std::string m_cssFile; }; } // namespace waybar diff --git a/include/util/css_reload_helper.hpp b/include/util/css_reload_helper.hpp new file mode 100644 index 00000000..62507e42 --- /dev/null +++ b/include/util/css_reload_helper.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct pollfd; + +namespace waybar { +class CssReloadHelper { + public: + CssReloadHelper(std::string cssFile, std::function callback); + + ~CssReloadHelper(); + + virtual void monitorChanges(); + + void stop(); + + protected: + std::vector parseImports(const std::string& cssFile); + + void parseImports(const std::string& cssFile, + std::unordered_map& imports); + + + void watchFiles(const std::vector& files); + + bool handleInotifyEvents(int fd); + + bool watch(int inotifyFd, pollfd * pollFd); + + virtual std::string getFileContents(const std::string& filename); + + virtual std::string findPath(const std::string& filename); + + private: + std::string m_cssFile; + std::function m_callback; + std::atomic m_running = false; + std::thread m_thread; + std::mutex m_mutex; + std::condition_variable m_cv; +}; +} // namespace waybar diff --git a/meson.build b/meson.build index c1ae48b5..d7549731 100644 --- a/meson.build +++ b/meson.build @@ -196,7 +196,8 @@ src_files = files( 'src/util/sanitize_str.cpp', 'src/util/rewrite_string.cpp', 'src/util/gtk_icon.cpp', - 'src/util/regex_collection.cpp' + 'src/util/regex_collection.cpp', + 'src/util/css_reload_helper.cpp' ) inc_dirs = ['include'] diff --git a/src/client.cpp b/src/client.cpp index 066247e7..ff1be5d8 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -262,15 +262,18 @@ int waybar::Client::main(int argc, char *argv[]) { if (!portal) { portal = std::make_unique(); } - auto css_file = getStyle(style_opt); - setupCss(css_file); + m_cssFile = getStyle(style_opt); + setupCss(m_cssFile); + m_cssReloadHelper = std::make_unique(m_cssFile, [&]() { setupCss(m_cssFile); }); portal->signal_appearance_changed().connect([&](waybar::Appearance appearance) { auto css_file = getStyle(style_opt, appearance); setupCss(css_file); }); + m_cssReloadHelper->monitorChanges(); bindInterfaces(); gtk_app->hold(); gtk_app->run(); + m_cssReloadHelper.reset(); // stop watching css file bars.clear(); return 0; } diff --git a/src/util/css_reload_helper.cpp b/src/util/css_reload_helper.cpp new file mode 100644 index 00000000..4038f1f3 --- /dev/null +++ b/src/util/css_reload_helper.cpp @@ -0,0 +1,214 @@ +#include "util/css_reload_helper.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +#include "config.hpp" +namespace { +const std::regex IMPORT_REGEX(R"(@import\s+(?:url\()?(?:"|')([^"')]+)(?:"|')\)?;)"); +} + +waybar::CssReloadHelper::CssReloadHelper(std::string cssFile, std::function callback) + : m_cssFile(std::move(cssFile)), m_callback(std::move(callback)) {} + +waybar::CssReloadHelper::~CssReloadHelper() { stop(); } + +std::string waybar::CssReloadHelper::getFileContents(const std::string& filename) { + if (filename.empty()) { + return {}; + } + + std::ifstream file(filename); + if (!file.is_open()) { + return {}; + } + + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); +} + +std::string waybar::CssReloadHelper::findPath(const std::string& filename) { + // try path and fallback to looking relative to the config + if (std::filesystem::exists(filename)) { + return filename; + } + + return Config::findConfigPath({filename}).value_or(""); +} + +void waybar::CssReloadHelper::monitorChanges() { + m_thread = std::thread([this] { + m_running = true; + while (m_running) { + auto files = parseImports(m_cssFile); + watchFiles(files); + } + }); +} + +std::vector waybar::CssReloadHelper::parseImports(const std::string& cssFile) { + std::unordered_map imports; + + auto cssFullPath = findPath(cssFile); + if (cssFullPath.empty()) { + spdlog::error("Failed to find css file: {}", cssFile); + return {}; + } + + spdlog::debug("Parsing imports for file: {}", cssFullPath); + imports[cssFullPath] = false; + + auto previousSize = 0UL; + auto maxIterations = 100U; + do { + previousSize = imports.size(); + for (const auto& [file, parsed] : imports) { + if (!parsed) { + parseImports(file, imports); + } + } + + } while (imports.size() > previousSize && maxIterations-- > 0); + + std::vector result; + for (const auto& [file, parsed] : imports) { + if (parsed) { + spdlog::debug("Adding file to watch list: {}", file); + result.push_back(file); + } + } + + return result; +} + +void waybar::CssReloadHelper::parseImports(const std::string& cssFile, + std::unordered_map& imports) { + // if the file has already been parsed, skip + if (imports.find(cssFile) != imports.end() && imports[cssFile]) { + return; + } + + auto contents = getFileContents(cssFile); + std::smatch matches; + while (std::regex_search(contents, matches, IMPORT_REGEX)) { + auto importFile = findPath({matches[1].str()}); + if (!importFile.empty() && imports.find(importFile) == imports.end()) { + imports[importFile] = false; + } + + contents = matches.suffix().str(); + } + + imports[cssFile] = true; +} + +void waybar::CssReloadHelper::stop() { + if (!m_running) { + return; + } + + m_running = false; + m_cv.notify_all(); + if (m_thread.joinable()) { + m_thread.join(); + } +} + +void waybar::CssReloadHelper::watchFiles(const std::vector& files) { + auto inotifyFd = inotify_init1(IN_NONBLOCK); + if (inotifyFd < 0) { + spdlog::error("Failed to initialize inotify: {}", strerror(errno)); + return; + } + + std::vector watchFds; + for (const auto& file : files) { + auto watchFd = inotify_add_watch(inotifyFd, file.c_str(), IN_MODIFY | IN_MOVED_TO); + if (watchFd < 0) { + spdlog::error("Failed to add watch for file: {}", file); + } else { + spdlog::debug("Added watch for file: {}", file); + } + watchFds.push_back(watchFd); + } + + auto pollFd = pollfd{inotifyFd, POLLIN, 0}; + + while (true) { + if (watch(inotifyFd, &pollFd)) { + break; + } + } + + for (const auto& watchFd : watchFds) { + inotify_rm_watch(inotifyFd, watchFd); + } + + close(inotifyFd); +} + +bool waybar::CssReloadHelper::watch(int inotifyFd, pollfd* pollFd) { + auto pollResult = poll(pollFd, 1, 10); + if (pollResult < 0) { + spdlog::error("Failed to poll inotify: {}", strerror(errno)); + return true; + } + + if (pollResult == 0) { + // check if we should stop + if (!m_running) { + return true; + } + + std::unique_lock lock(m_mutex); + // a condition variable is used to allow the thread to be stopped immediately while still not + // spamming poll + m_cv.wait_for(lock, std::chrono::milliseconds(250), [this] { return !m_running; }); + + // timeout + return false; + } + + if (static_cast(pollFd->revents & POLLIN)) { + if (handleInotifyEvents(inotifyFd)) { + // after the callback is fired we need to re-parse the imports and setup the watches + // again in case the import list has changed + return true; + } + } + + return false; +} + +bool waybar::CssReloadHelper::handleInotifyEvents(int inotify_fd) { + // inotify event + auto buffer = std::array{}; + auto readResult = read(inotify_fd, buffer.data(), buffer.size()); + if (readResult < 0) { + spdlog::error("Failed to read inotify event: {}", strerror(errno)); + return false; + } + + auto offset = 0; + auto shouldFireCallback = false; + + // read all events on the fd + while (offset < readResult) { + auto* event = reinterpret_cast(buffer.data() + offset); + offset += sizeof(inotify_event) + event->len; + shouldFireCallback = true; + } + + // we only need to fire the callback once + if (shouldFireCallback) { + m_callback(); + return true; + } + + return false; +} diff --git a/test/css_reload_helper.cpp b/test/css_reload_helper.cpp new file mode 100644 index 00000000..3ee1fb6e --- /dev/null +++ b/test/css_reload_helper.cpp @@ -0,0 +1,188 @@ +#include "util/css_reload_helper.hpp" +#include +#include + +#if __has_include() +#include +#include +#else +#include +#endif + + +class CssReloadHelperTest : public waybar::CssReloadHelper +{ +public: + CssReloadHelperTest() + : CssReloadHelper("/tmp/waybar_test.css", [this]() {callback();}) + { + } + + void callback() + { + m_callbackCounter++; + } + +protected: + + std::string getFileContents(const std::string& filename) override + { + return m_fileContents[filename]; + } + + std::string findPath(const std::string& filename) override + { + return filename; + } + + void setFileContents(const std::string& filename, const std::string& contents) + { + m_fileContents[filename] = contents; + } + + int getCallbackCounter() const + { + return m_callbackCounter; + } + +private: + int m_callbackCounter{}; + std::map m_fileContents; +}; + +TEST_CASE_METHOD(CssReloadHelperTest, "parse_imports", "[util][css_reload_helper]") +{ + SECTION("no imports") + { + setFileContents("/tmp/waybar_test.css", "body { color: red; }"); + auto files = parseImports("/tmp/waybar_test.css"); + REQUIRE(files.size() == 1); + CHECK(files[0] == "/tmp/waybar_test.css"); + } + + SECTION("single import") + { + setFileContents("/tmp/waybar_test.css", "@import 'test.css';"); + setFileContents("test.css", "body { color: red; }"); + auto files = parseImports("/tmp/waybar_test.css"); + std::sort(files.begin(), files.end()); + REQUIRE(files.size() == 2); + CHECK(files[0] == "/tmp/waybar_test.css"); + CHECK(files[1] == "test.css"); + } + + SECTION("multiple imports") + { + setFileContents("/tmp/waybar_test.css", "@import 'test.css'; @import 'test2.css';"); + setFileContents("test.css", "body { color: red; }"); + setFileContents("test2.css", "body { color: blue; }"); + auto files = parseImports("/tmp/waybar_test.css"); + std::sort(files.begin(), files.end()); + REQUIRE(files.size() == 3); + CHECK(files[0] == "/tmp/waybar_test.css"); + CHECK(files[1] == "test.css"); + CHECK(files[2] == "test2.css"); + } + + SECTION("nested imports") + { + setFileContents("/tmp/waybar_test.css", "@import 'test.css';"); + setFileContents("test.css", "@import 'test2.css';"); + setFileContents("test2.css", "body { color: red; }"); + auto files = parseImports("/tmp/waybar_test.css"); + std::sort(files.begin(), files.end()); + REQUIRE(files.size() == 3); + CHECK(files[0] == "/tmp/waybar_test.css"); + CHECK(files[1] == "test.css"); + CHECK(files[2] == "test2.css"); + } + + SECTION("circular imports") + { + setFileContents("/tmp/waybar_test.css", "@import 'test.css';"); + setFileContents("test.css", "@import 'test2.css';"); + setFileContents("test2.css", "@import 'test.css';"); + auto files = parseImports("/tmp/waybar_test.css"); + std::sort(files.begin(), files.end()); + REQUIRE(files.size() == 3); + CHECK(files[0] == "/tmp/waybar_test.css"); + CHECK(files[1] == "test.css"); + CHECK(files[2] == "test2.css"); + } + + SECTION("empty") + { + setFileContents("/tmp/waybar_test.css", ""); + auto files = parseImports("/tmp/waybar_test.css"); + REQUIRE(files.size() == 1); + CHECK(files[0] == "/tmp/waybar_test.css"); + } + + SECTION("empty name") + { + auto files = parseImports(""); + REQUIRE(files.empty()); + } +} + +TEST_CASE("file_watcher", "[util][css_reload_helper]") +{ + SECTION("file does not exist") + { + std::atomic count; + std::string f1 = std::tmpnam(nullptr); + waybar::CssReloadHelper helper(f1, [&count](){++count;}); + helper.monitorChanges(); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + CHECK(count == 0); + helper.stop(); + std::remove(f1.c_str()); + } + + SECTION("file exists") + { + std::atomic count; + std::string f1 = std::tmpnam(nullptr); + std::ofstream(f1) << "body { color: red; }"; + waybar::CssReloadHelper helper(f1, [&count](){++count;}); + helper.monitorChanges(); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + CHECK(count == 0); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::ofstream(f1) << "body { color: blue; }"; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + CHECK(count == 1); + helper.stop(); + std::remove(f1.c_str()); + } + + SECTION("multiple files") + { + std::atomic count; + std::string f1 = std::tmpnam(nullptr); + std::string f2 = std::tmpnam(nullptr); + std::ofstream(f1) << ("@import '" + f2 + " ';"); + std::ofstream(f2) << "body { color: red; }"; + waybar::CssReloadHelper helper(f1, [&count](){++count;}); + helper.monitorChanges(); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + CHECK(count == 0); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + std::ofstream(f2) << "body { color: blue; }"; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + CHECK(count == 1); + helper.stop(); + std::remove(f1.c_str()); + std::remove(f2.c_str()); + } +} diff --git a/test/meson.build b/test/meson.build index 02cbb2a4..358d6c96 100644 --- a/test/meson.build +++ b/test/meson.build @@ -10,7 +10,9 @@ test_src = files( 'main.cpp', 'SafeSignal.cpp', 'config.cpp', + 'css_reload_helper.cpp', '../src/config.cpp', + '../src/util/css_reload_helper.cpp', ) if tz_dep.found()