From d4e936a7e23730a003412839a84c1807cfb3c3e8 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 13:53:37 +0200 Subject: [PATCH] add nightlight hyprland plugin + rename hyprsunsetmanager -> nightlightmanager to control both --- Helpers/Hyprsunset.qml | 4 +- Plugins/ZShell/CMakeLists.txt | 8 + Plugins/ZShell/Config/general.hpp | 2 + Plugins/ZShell/HyprPlugins/CMakeLists.txt | 12 + Plugins/ZShell/HyprPlugins/colortemp.hpp | 37 ++ Plugins/ZShell/HyprPlugins/main.cpp | 457 ++++++++++++++++++ Plugins/ZShell/Services/CMakeLists.txt | 4 +- Plugins/ZShell/Services/hyprsunsetmanager.cpp | 148 ------ Plugins/ZShell/Services/hyprsunsetmanager.hpp | 71 --- Plugins/ZShell/Services/nightlightmanager.cpp | 444 +++++++++++++++++ Plugins/ZShell/Services/nightlightmanager.hpp | 122 +++++ 11 files changed, 1087 insertions(+), 222 deletions(-) create mode 100644 Plugins/ZShell/HyprPlugins/CMakeLists.txt create mode 100644 Plugins/ZShell/HyprPlugins/colortemp.hpp create mode 100644 Plugins/ZShell/HyprPlugins/main.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.hpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.cpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.hpp diff --git a/Helpers/Hyprsunset.qml b/Helpers/Hyprsunset.qml index ca82be2..540091d 100644 --- a/Helpers/Hyprsunset.qml +++ b/Helpers/Hyprsunset.qml @@ -26,12 +26,14 @@ Singleton { service.toggle(); } - HyprsunsetManager { + NightlightManager { id: service activeAuto: Config.general.color.scheduleHyprsunset endTime: root.end startTime: root.start temp: root.temp + fadeDuration: Config.general.color.nativeFadeDuration / 1000 + useNativeNightlight: Config.general.color.useNativeNightlight } } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index a20edf1..9e7303f 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -80,3 +80,11 @@ add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) add_subdirectory(Llm) + +pkg_check_modules(HYPRLAND_PROBE hyprland) +if(HYPRLAND_PROBE_FOUND) + add_subdirectory(HyprPlugins) +else() + message(STATUS + "hyprland development headers not found; skipping zshell-nightlight plugin") +endif() diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9d0f8fd..9f6a70f 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -98,6 +98,8 @@ class ColorSettings : public ConfigObject { CFG_PROPERTY(int, hyprsunsetTemp, 2600) CFG_PROPERTY(QString, mode, QStringLiteral("dark")) CFG_PROPERTY(bool, neovimColors, false) + CFG_PROPERTY(bool, useNativeNightlight, true) + CFG_PROPERTY(int, nativeFadeDuration, 2000) CFG_PROPERTY(bool, scheduleDark, false) CFG_PROPERTY(int, scheduleDarkEnd, 600) CFG_PROPERTY(int, scheduleDarkStart, 1140) diff --git a/Plugins/ZShell/HyprPlugins/CMakeLists.txt b/Plugins/ZShell/HyprPlugins/CMakeLists.txt new file mode 100644 index 0000000..0316838 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/CMakeLists.txt @@ -0,0 +1,12 @@ +pkg_check_modules(HYPRLAND REQUIRED hyprland) + +add_library(zshell-nightlight SHARED main.cpp) + +target_compile_features(zshell-nightlight PRIVATE cxx_std_23) +target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS}) +target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS}) +target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER}) + +target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique) + +install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins") diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp new file mode 100644 index 0000000..25f9c05 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +namespace ZShell::services { + +inline std::array kelvinToGain(int kelvin) { + const double t = std::clamp(kelvin, 1000, 40000) / 100.0; + double r, g, b; + + r = t <= 66.0 + ? 1.0 + : std::clamp( + 1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0); + g = t <= 66.0 + ? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0) + : std::clamp( + 1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0); + b = t >= 66.0 + ? 1.0 + : (t <= 19.0 + ? 0.0 + : std::clamp( + 0.54320678911 * std::log(t - 10.0) - 1.19625408914, + 0.0, + 1.0)); + + return {static_cast(r), static_cast(g), static_cast(b)}; +} + +inline float easeInOutCubic(float x) { + return x < 0.5f ? 4.f * x * x * x + : 1.f - std::pow(-2.f * x + 2.f, 3.f) / 2.f; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp new file mode 100644 index 0000000..ad2080e --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -0,0 +1,457 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include +#include +#include +#undef private + +#include + +extern "C" { +#include +#include +} + +#include "colortemp.hpp" + +using ZShell::services::easeInOutCubic; +using ZShell::services::kelvinToGain; + +using Render::IFramebuffer; +using Render::GL::CHyprOpenGLImpl; +using Render::GL::g_pHyprOpenGL; + +inline HANDLE PHANDLE = nullptr; + +static std::filesystem::path g_runtimeDir{"/tmp"}; +static std::filesystem::path g_cacheBase{"/tmp/.cache"}; + +static void initEnvPaths() { + if (const char* env = getenv("XDG_RUNTIME_DIR")) + g_runtimeDir = env; + + if (const char* env = getenv("XDG_CACHE_HOME")) { + if (*env) + g_cacheBase = env; + } else if (const char* home = getenv("HOME")) { + g_cacheBase = std::filesystem::path(home) / ".cache"; + } +} + +static std::ofstream& debugLog() { + static std::ofstream log( + g_runtimeDir / "zshell-nightlight-debug.log", + std::ios::app); + return log; +} + +static std::mutex g_logMutex; +static std::atomic g_logSeq{0}; + +static void dlog(const std::string& msg) { + std::lock_guard lk(g_logMutex); + auto& log = debugLog(); + log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] " + << msg << std::endl; +} + +static constexpr int BAKED_STEPS = 24; + +static std::string glslFloat(float v) { + std::ostringstream ss; + ss << std::fixed << std::setprecision(6) << v; + return ss.str(); +} + +static std::filesystem::path cacheDir() { + return g_cacheBase / "zshell" / "night-light"; +} + +static std::filesystem::path shaderPath() { + return cacheDir() / "nightlight.frag"; +} + +static std::filesystem::path bakedShaderPath(const std::array& gain) { + return cacheDir() / + ("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) + + "-" + glslFloat(gain[2]) + ".frag"); +} + +static std::string defaultShaderSource() { + return "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "uniform vec3 tint;\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n" + "}\n"; +} + +static std::string bakedShaderSource(const std::array& gain) { + std::ostringstream src; + src << "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "const vec3 GAIN = vec3(" + << glslFloat(gain[0]) << "," << glslFloat(gain[1]) << "," + << glslFloat(gain[2]) + << ");\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n" + "}\n"; + return src.str(); +} + +static void ensureShaderFile( + const std::filesystem::path& path, const std::string& source) { + std::error_code ec; + if (std::filesystem::is_regular_file(path, ec)) return; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + out << source; +} + +static bool g_active = false; +static bool g_uniformMode = false; +static bool g_modeDecided = false; +static std::array g_currentGain{1.f, 1.f, 1.f}; + +static int g_currentKelvin = 6500; +static int g_lastKelvin = 6500; + +struct STransition { + bool active = false; + std::chrono::steady_clock::time_point start; + float durationSec = 0.4f; + std::array fromGain{1.f, 1.f, 1.f}; + std::array toGain{1.f, 1.f, 1.f}; + int lastBakedStep = -1; +}; +static STransition g_transition; + +static void postStateEvent(bool enabled, bool animating); + +static bool gainIsNeutral(const std::array& gain) { + for (float v : gain) + if (std::abs(v - 1.f) > 0.001f) return false; + return true; +} + +static CFunctionHook* g_pBeginHook = nullptr; +using origBegin = void (*)( + CHyprOpenGLImpl*, + PHLMONITOR, + const CRegion&, + SP, + std::optional); + +static CFunctionHook* g_pSaveMirrorHook = nullptr; +using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&); + +static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { + const bool saved = thisptr->m_applyFinalShader; + thisptr->m_applyFinalShader = false; + const bool result = (*reinterpret_cast( + g_pSaveMirrorHook->m_original))(thisptr, box); + thisptr->m_applyFinalShader = saved; + return result; +} + +static void installShader() { + auto* gl = g_pHyprOpenGL.get(); + if (!gl) + return; + + ensureShaderFile(shaderPath(), defaultShaderSource()); + gl->applyScreenShader(shaderPath().string()); + + auto* shader = gl->m_finalScreenShader.get(); + if (shader && shader->program() >= 1 && + shader->getUniformLocation(SHADER_TINT) != -1) { + g_uniformMode = true; + } else { + g_uniformMode = false; + if (!g_modeDecided) + dlog( + "shader has no 'tint' uniform, falling back to " + "per-step recompiled shaders"); + } + g_modeDecided = true; +} + +static void setUniformGain(const std::array& gain) { + auto* gl = g_pHyprOpenGL.get(); + auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; + if (!gl || !shader) + return; + + gl->useShader(gl->m_finalScreenShader); + shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); +} + +static void hkBegin( + CHyprOpenGLImpl* thisptr, + PHLMONITOR mon, + const CRegion& damage, + SP fb, + std::optional finalDamage) { + (*reinterpret_cast( + g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); + + if (fb) { + auto* renderer = g_pHyprRenderer.get(); + if (renderer) + renderer->m_renderData.blockScreenShader = true; + } + + if (!g_active) return; + + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + if (gl->m_finalScreenShader.get() && + gl->m_finalScreenShader->program() < 1) + installShader(); + if (!gl->m_finalScreenShader.get() || + gl->m_finalScreenShader->program() < 1) + return; + + if (g_transition.active) { + auto* monPtr = mon.get(); + if (!monPtr) + return; + const float elapsed = + std::chrono::duration( + std::chrono::steady_clock::now() - g_transition.start) + .count(); + const float t = + std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); + const float e = easeInOutCubic(t); + + for (size_t i = 0; i < 3; ++i) + g_currentGain[i] = + g_transition.fromGain[i] + + (g_transition.toGain[i] - g_transition.fromGain[i]) * e; + + if (g_uniformMode) { + setUniformGain(g_currentGain); + } else { + const int step = static_cast(e * BAKED_STEPS); + if (step != g_transition.lastBakedStep) { + g_transition.lastBakedStep = step; + ensureShaderFile( + bakedShaderPath(g_currentGain), + bakedShaderSource(g_currentGain)); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } + } + + if (t >= 1.f) { + g_transition.active = false; + if (gainIsNeutral(g_currentGain)) { + g_active = false; + gl->applyScreenShader(""); + } + postStateEvent(g_active, false); + } else { + monPtr->m_forceFullFrames = 2; + monPtr->scheduleFrame(); + } + } else if (g_uniformMode) { + setUniformGain(g_currentGain); + } +} + +static constexpr int KELVIN_MIN = 2000; +static constexpr int KELVIN_MAX = 6500; + +static int luaNightlightSet(lua_State* L) { + const int rawKelvin = static_cast(luaL_checknumber(L, 1)); + const float durationSec = + lua_gettop(L) >= 2 ? static_cast(luaL_checknumber(L, 2)) : 0.4f; + + const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX); + if (targetKelvin != rawKelvin) + dlog( + "clamped " + std::to_string(rawKelvin) + "K to " + + std::to_string(targetKelvin) + "K"); + + g_transition.fromGain = g_currentGain; + g_transition.toGain = kelvinToGain(targetKelvin); + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + g_active = true; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = targetKelvin; + + postStateEvent(true, true); + + lua_pushinteger(L, targetKelvin); + return 1; +} + +static int luaNightlightDisable(lua_State* L) { + if (!g_active) return 0; + + const float durationSec = + lua_gettop(L) >= 1 ? static_cast(luaL_checknumber(L, 1)) : 0.4f; + + g_transition.fromGain = g_currentGain; + g_transition.toGain = {1.f, 1.f, 1.f}; + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = 6500; + + postStateEvent(false, true); + + return 0; +} + +static int luaNightlightState(lua_State* L) { + lua_pushstring( + L, + std::format( + "{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}", + g_active ? "true" : "false", + g_transition.active ? "true" : "false", + g_lastKelvin, + g_currentKelvin) + .c_str()); + return 1; +} + +static void postStateEvent(bool enabled, bool animating) { + if (!g_pEventManager) return; + g_pEventManager->postEvent( + SHyprIPCEvent{ + .event = "zshell-nightlight", + .data = std::format( + "{},{},{},{}", + enabled ? 1 : 0, + animating ? 1 : 0, + g_lastKelvin, + g_currentKelvin)}); +} + +static const SFunctionMatch* findExactMethod( + const std::vector& methods, + const std::string& classAndMethod) { + for (const auto& m : methods) { + if (m.signature.find(classAndMethod) != std::string::npos) return &m; + } + return nullptr; +} + +static std::string manglePrefix( + const std::string& className, const std::string& methodName) { + return std::to_string(className.size()) + className + + std::to_string(methodName.size()) + methodName; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + initEnvPaths(); + dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ==="); + + const std::string serverHash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (serverHash != clientHash) { + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] Mismatched headers! Can't proceed.", + CHyprColor{1.0f, 0.2f, 0.2f, 1.0f}, + 5000); + throw std::runtime_error("[zshell-nightlight] version mismatch"); + } + + static const auto SAVEMIRROR_METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror"); + const SFunctionMatch* saveMirrorTarget = findExactMethod( + SAVEMIRROR_METHODS, + manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror")); + + if (saveMirrorTarget) { + g_pSaveMirrorHook = HyprlandAPI::createFunctionHook( + PHANDLE, + saveMirrorTarget->address, + reinterpret_cast(&hkSaveBufferForMirror)); + if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook()) + dlog( + "saveBufferForMirror hook installed: " + + saveMirrorTarget->signature); + } + + if (!g_pSaveMirrorHook) + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — " + "captures may still show the nightlight", + CHyprColor{1.f, 0.6f, 0.2f, 1.f}, + 8000); + + static const auto METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); + const SFunctionMatch* target = + findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin")); + if (!target) + throw std::runtime_error( + "[zshell-nightlight] couldn't uniquely resolve " + "CHyprOpenGLImpl::begin"); + + g_pBeginHook = HyprlandAPI::createFunctionHook( + PHANDLE, target->address, reinterpret_cast(&hkBegin)); + if (!g_pBeginHook || !g_pBeginHook->hook()) + throw std::runtime_error("[zshell-nightlight] failed to hook begin()"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlSet", luaNightlightSet)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlDisable", luaNightlightDisable)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlState", luaNightlightState)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + return { + "zshell-nightlight", + "Animated nightlight, excluded from screencopy", + "ZShell", + "1.2"}; +} + + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + + +APICALL EXPORT void PLUGIN_EXIT() {} diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 7d1d2ff..2dfe707 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -9,7 +9,7 @@ qml_module(ZShell-services cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp - hyprsunsetmanager.hpp hyprsunsetmanager.cpp + nightlightmanager.hpp nightlightmanager.cpp tickingservice.hpp tickingservice.cpp sensorslib.hpp sensorslib.cpp usagefmt.hpp usagefmt.cpp @@ -25,4 +25,4 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors -) + ) diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp deleted file mode 100644 index 0b33f6f..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "hyprsunsetmanager.hpp" -#include -#include -#include -#include - -namespace ZShell::services { - -HyprsunsetManager::HyprsunsetManager(QObject* parent) : QObject(parent) { - connect(&m_timer, &QTimer::timeout, this, &HyprsunsetManager::apply); - connect(&m_manualTimer, &QTimer::timeout, this, [this] { - m_manualToggle = false; - emit manualToggleChanged(); - apply(); - }); - connect(&m_startCooldown, &QTimer::timeout, this, [this] { - m_startAllowed = true; - apply(); - }); - - m_startCooldown.start(2000); - m_manualTimer.setSingleShot(true); - m_timer.start(60000); - - m_process.setStandardInputFile(QProcess::nullDevice()); - m_process.setStandardOutputFile(QProcess::nullDevice()); -} - -int HyprsunsetManager::startTime() const { - return m_startTime; -} - -int HyprsunsetManager::endTime() const { - return m_endTime; -} - -bool HyprsunsetManager::manualToggle() const { - return m_manualToggle; -} - -bool HyprsunsetManager::enabled() const { - return m_enabled; -} - -bool HyprsunsetManager::activeAuto() const { - return m_activeAuto; -} - -int HyprsunsetManager::temp() const { - return m_temp; -} - -void HyprsunsetManager::setActiveAuto(bool activeAuto) { - if (activeAuto == m_activeAuto) return; - - m_activeAuto = activeAuto; - emit activeAutoChanged(); -} - -void HyprsunsetManager::setManualToggle(bool toggle) { - if (toggle == m_manualToggle) return; - - m_manualToggle = toggle; - emit manualToggleChanged(); - - m_manualTimer.start(60 * 60 * 1000); -} - -void HyprsunsetManager::setEndTime(const int& time) { - if (time == m_endTime) return; - - m_endTime = time; - emit endTimeChanged(); - apply(); -} - -void HyprsunsetManager::setStartTime(const int& time) { - if (time == m_startTime) return; - - m_startTime = time; - emit startTimeChanged(); - apply(); -} - -void HyprsunsetManager::setTemp(const int& temp) { - if (temp == m_temp) return; - - m_temp = temp; - emit tempChanged(); - apply(); -} - -void HyprsunsetManager::toggle() { - if (m_enabled) { - end(); - } else { - start(); - } -} - -void HyprsunsetManager::start() { - if (m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = true; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments( - {"hyprsunset", "temperature", QString::number(m_temp)}); - m_process.startDetached(); -} - -void HyprsunsetManager::end() { - if (!m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = false; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments({"hyprsunset", "identity"}); - m_process.startDetached(); -} - -void HyprsunsetManager::apply() { - if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - - const auto current = QTime::currentTime(); - const auto currentMin = current.hour() * 60 + current.minute(); - bool isDarkTime = false; - - if (m_startTime <= m_endTime) { - isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); - } else { - isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); - } - - if (isDarkTime) { - start(); - } else { - end(); - } -} - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.hpp b/Plugins/ZShell/Services/hyprsunsetmanager.hpp deleted file mode 100644 index 7241c0d..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace ZShell::services { - -class HyprsunsetManager : public QObject { - Q_OBJECT - QML_ELEMENT - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) - Q_PROPERTY( - int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) - Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) - Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) - Q_PROPERTY( - bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY - activeAutoChanged) - Q_PROPERTY( - bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY - manualToggleChanged) - - public: - explicit HyprsunsetManager(QObject* parent = nullptr); - - [[nodiscard]] int startTime() const; - [[nodiscard]] int endTime() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] int temp() const; - [[nodiscard]] bool activeAuto() const; - [[nodiscard]] bool manualToggle() const; - - Q_INVOKABLE void toggle(); - Q_INVOKABLE void apply(); - - void setStartTime(const int& time); - void setEndTime(const int& time); - void setTemp(const int& temp); - void setActiveAuto(bool activeAuto); - void setManualToggle(bool toggle); - - signals: - void enabledChanged(); - void startTimeChanged(); - void activeAutoChanged(); - void endTimeChanged(); - void tempChanged(); - void manualToggleChanged(); - - private: - int m_startTime; - int m_endTime; - bool m_enabled = false; - bool m_manualToggle = false; - bool m_activeAuto; - bool m_startAllowed = false; - bool m_initialized = false; - QTimer m_startCooldown; - int m_temp; - QProcess m_process; - QTimer m_timer; - QTimer m_manualTimer; - void start(); - void end(); -}; - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp new file mode 100644 index 0000000..78658d4 --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -0,0 +1,444 @@ +#include "nightlightmanager.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace { +constexpr const char* NL_EVENT = "zshell-nightlight"; +constexpr int NEUTRAL_KELVIN = 6500; +constexpr int RECONNECT_MS = 2000; +} // namespace + +NightlightManager::NightlightManager(QObject* parent) : QObject(parent) { + connect(&m_timer, &QTimer::timeout, this, &NightlightManager::apply); + connect(&m_manualTimer, &QTimer::timeout, this, [this] { + m_manualToggle = false; + emit manualToggleChanged(); + apply(); + }); + connect(&m_startCooldown, &QTimer::timeout, this, [this] { + m_startAllowed = true; + apply(); + }); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this] { + if (!m_useNativeNightlight) return; + + connectEventSocket(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + bootstrapState(); + }); + + m_startCooldown.start(2000); + m_manualTimer.setSingleShot(true); + m_reconnectTimer.setSingleShot(true); + m_timer.start(60000); + + m_process.setStandardInputFile(QProcess::nullDevice()); + m_process.setStandardOutputFile(QProcess::nullDevice()); +} + +int NightlightManager::startTime() const { + return m_startTime; +} + +int NightlightManager::endTime() const { + return m_endTime; +} + +bool NightlightManager::manualToggle() const { + return m_manualToggle; +} + +bool NightlightManager::enabled() const { + return m_enabled; +} + +bool NightlightManager::activeAuto() const { + return m_activeAuto; +} + +int NightlightManager::temp() const { + return m_temp; +} + +double NightlightManager::fadeDuration() const { + return m_fadeDuration; +} + +bool NightlightManager::animating() const { + return m_animating; +} + +int NightlightManager::lastTemp() const { + return m_lastTemp; +} + +int NightlightManager::currentTemp() const { + return m_currentTemp; +} + +void NightlightManager::setActiveAuto(bool activeAuto) { + if (activeAuto == m_activeAuto) return; + + m_activeAuto = activeAuto; + emit activeAutoChanged(); +} + +void NightlightManager::setManualToggle(bool toggle) { + if (toggle == m_manualToggle) return; + + m_manualToggle = toggle; + emit manualToggleChanged(); + + m_manualTimer.start(60 * 60 * 1000); +} + +void NightlightManager::setEndTime(const int& time) { + if (time == m_endTime) return; + + m_endTime = time; + emit endTimeChanged(); + apply(); +} + +void NightlightManager::setStartTime(const int& time) { + if (time == m_startTime) return; + + m_startTime = time; + emit startTimeChanged(); + apply(); +} + +void NightlightManager::setTemp(const int& temp) { + if (temp == m_temp) return; + + m_temp = temp; + emit tempChanged(); + apply(); +} + +void NightlightManager::setFadeDuration(double duration) { + if (duration < 0.0) duration = 0.0; + + m_fadeDuration = duration; + emit fadeDurationChanged(); +} + +void NightlightManager::toggle() { + if (m_enabled) { + end(); + } else { + start(); + } +} + +void NightlightManager::start() { + if (m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = true; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlSet(%1, %2)") + .arg(m_temp) + .arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"hyprsunset", "temperature", QString::number(m_temp)}); + m_process.startDetached(); + } +} + +void NightlightManager::end() { + if (!m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = false; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlDisable(%1)").arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments({"hyprsunset", "identity"}); + m_process.startDetached(); + } +} + +void NightlightManager::apply() { + if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; + + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime = false; + + if (m_startTime <= m_endTime) { + isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); + } else { + isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); + } + + if (isDarkTime) { + start(); + } else { + end(); + } +} + +bool NightlightManager::useNativeNightlight() const { + return m_useNativeNightlight; +} + +QString NightlightManager::pluginPath() const { + return m_pluginPath; +} + +void NightlightManager::setPluginPath(const QString& path) { + if (path == m_pluginPath) return; + + m_pluginPath = path; + emit pluginPathChanged(); +} + +void NightlightManager::setUseNativeNightlight(bool native) { + if (native == m_useNativeNightlight) return; + + m_useNativeNightlight = native; + emit useNativeNightlightChanged(); + + if (native) { + disconnect(&m_eventSocket, nullptr, this, nullptr); + connect( + &m_eventSocket, + &QLocalSocket::readyRead, + this, + &NightlightManager::handleEventLines); + connect( + &m_eventSocket, + &QLocalSocket::errorOccurred, + this, + &NightlightManager::scheduleReconnect); + connectEventSocket(); + bootstrapState(); + } else { + m_reconnectTimer.stop(); + disconnect(&m_eventSocket, nullptr, this, nullptr); + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) + m_eventSocket.disconnectFromServer(); + m_socketBuffer.clear(); + m_animating = false; + emit animatingChanged(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + } +} + +QString NightlightManager::hyprlandDir() const { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) return QString(); + + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + auto dir = runtimeDir + "/hypr/" + his; + if (!QFileInfo(dir).isDir()) dir = "/tmp/hypr/" + his; + + if (!QFileInfo(dir).isDir()) return QString(); + + return dir; +} + +QString NightlightManager::eventSocketPath() const { + const auto dir = hyprlandDir(); + return dir.isEmpty() ? QString() : dir + "/.socket2.sock"; +} + +QString NightlightManager::resolvePluginPath() const { + if (!m_pluginPath.isEmpty()) return m_pluginPath; + + const QString name = QStringLiteral("libzshell-nightlight.so"); + const QStringList dirs = { + "/usr/lib/ZShell/plugins", + "/usr/local/lib/ZShell/plugins", + }; + for (const auto& dir : dirs) { + const QString candidate = dir + "/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString home = qEnvironmentVariable("HOME"); + if (!home.isEmpty()) { + const QString candidate = home + "/.local/lib/ZShell/plugins/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + return QString(); +} + +void NightlightManager::tryLoadPlugin() { + if (m_pluginLoadAttempted) return; + + const auto path = resolvePluginPath(); + if (path.isEmpty()) { + qWarning() << "NightlightManager: plugin not found; searched " + "pluginPath and /usr/lib/ZShell/plugins"; + m_pluginLoadAttempted = true; + return; + } + + m_pluginLoadAttempted = true; + m_pluginLoadedByUs = true; + qInfo() << "NightlightManager: plugin not loaded, loading" << path; + QProcess::startDetached("hyprctl", {"plugin", "load", path}); + + QTimer::singleShot(1500, this, [this] { bootstrapState(); }); +} + +void NightlightManager::bootstrapState() { + if (!m_useNativeNightlight) return; + + const auto dir = hyprlandDir(); + if (dir.isEmpty()) return; + + auto* sock = new QLocalSocket(this); + auto* watchdog = new QTimer(this); + watchdog->setSingleShot(true); + + const auto finish = [sock, watchdog]() { + watchdog->stop(); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QLocalSocket::connected, sock, [sock] { + sock->write("repl return hl.plugin.zshell.nlState()"); + sock->flush(); + }); + connect(sock, &QLocalSocket::readyRead, sock, [this, sock, finish]() { + const auto raw = sock->readAll(); + const auto doc = QJsonDocument::fromJson(raw); + finish(); + if (!doc.isObject()) { + if (m_pluginLoadAttempted) + qWarning() << "NightlightManager: plugin load failed, state " + << "query still answered:" << raw; + + tryLoadPlugin(); + return; + } + + if (m_pluginLoadedByUs) { + qInfo() << "NightlightManager: plugin was not loaded, manager " + << "loaded it; state:" << raw; + m_pluginLoadedByUs = false; + } else { + qInfo() << "NightlightManager: plugin already loaded, state:" + << raw; + } + + const auto obj = doc.object(); + updateState( + obj.value("enabled").toBool(), + obj.value("animating").toBool(), + obj.value("last").toInt(), + obj.value("current").toInt()); + }); + connect(sock, &QLocalSocket::errorOccurred, sock, finish); + connect(watchdog, &QTimer::timeout, sock, finish); + watchdog->start(2500); + + sock->connectToServer(dir + "/.socket.sock"); +} + +void NightlightManager::connectEventSocket() { + if (!m_useNativeNightlight) return; + + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) return; + + const auto path = eventSocketPath(); + if (path.isEmpty()) { + scheduleReconnect(); + return; + } + + m_socketBuffer.clear(); + m_eventSocket.connectToServer(path, QLocalSocket::ReadOnly); +} + +void NightlightManager::scheduleReconnect() { + if (!m_useNativeNightlight) return; + + m_reconnectTimer.start(RECONNECT_MS); +} + +void NightlightManager::handleEventLines() { + m_socketBuffer.append(m_eventSocket.readAll()); + + qsizetype idx = 0; + while ((idx = m_socketBuffer.indexOf('\n')) >= 0) { + const auto line = m_socketBuffer.left(idx); + m_socketBuffer.remove(0, idx + 1); + handleEventLine(line); + } +} + +void NightlightManager::handleEventLine(const QByteArray& line) { + const qsizetype sep = line.indexOf(">>"); + if (sep <= 0) return; + + if (line.left(sep) != NL_EVENT) return; + + // data = enabled,animating,last,current + const auto fields = line.mid(sep + 2).split(','); + if (fields.size() < 4) return; + + bool ok = false; + const auto enabled = fields.at(0).toInt(&ok); + if (!ok) return; + const auto animating = fields.at(1).toInt(&ok); + if (!ok) return; + const auto last = fields.at(2).toInt(&ok); + if (!ok) return; + const auto current = fields.at(3).toInt(&ok); + if (!ok) return; + + updateState(enabled == 1, animating == 1, last, current); +} + +void NightlightManager::updateState( + bool enabled, bool animating, int last, int current) { + if (enabled != m_enabled) { + m_enabled = enabled; + emit enabledChanged(); + } + if (animating != m_animating) { + m_animating = animating; + emit animatingChanged(); + } + if (last != m_lastTemp) { + m_lastTemp = last; + emit lastTempChanged(); + } + if (current != m_currentTemp) { + m_currentTemp = current; + emit currentTempChanged(); + } +} + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp new file mode 100644 index 0000000..43b341d --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class NightlightManager : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY( + int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) + Q_PROPERTY( + double fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) + Q_PROPERTY( + bool useNativeNightlight READ useNativeNightlight WRITE + setUseNativeNightlight NOTIFY useNativeNightlightChanged) + Q_PROPERTY( + QString pluginPath READ pluginPath WRITE setPluginPath NOTIFY + pluginPathChanged) + Q_PROPERTY( + bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY + activeAutoChanged) + Q_PROPERTY( + bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY + manualToggleChanged) + Q_PROPERTY(bool animating READ animating NOTIFY animatingChanged) + Q_PROPERTY(int lastTemp READ lastTemp NOTIFY lastTempChanged) + Q_PROPERTY(int currentTemp READ currentTemp NOTIFY currentTempChanged) + + public: + explicit NightlightManager(QObject* parent = nullptr); + + [[nodiscard]] int startTime() const; + [[nodiscard]] int endTime() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] int temp() const; + [[nodiscard]] double fadeDuration() const; + [[nodiscard]] bool useNativeNightlight() const; + [[nodiscard]] QString pluginPath() const; + [[nodiscard]] bool activeAuto() const; + [[nodiscard]] bool manualToggle() const; + [[nodiscard]] bool animating() const; + [[nodiscard]] int lastTemp() const; + [[nodiscard]] int currentTemp() const; + + Q_INVOKABLE void toggle(); + Q_INVOKABLE void apply(); + + void setStartTime(const int& time); + void setEndTime(const int& time); + void setTemp(const int& temp); + void setFadeDuration(double duration); + void setUseNativeNightlight(bool native); + void setPluginPath(const QString& path); + void setActiveAuto(bool activeAuto); + void setManualToggle(bool toggle); + + signals: + void enabledChanged(); + void startTimeChanged(); + void activeAutoChanged(); + void endTimeChanged(); + void tempChanged(); + void fadeDurationChanged(); + void useNativeNightlightChanged(); + void pluginPathChanged(); + void manualToggleChanged(); + void animatingChanged(); + void lastTempChanged(); + void currentTempChanged(); + + private: + int m_startTime; + int m_endTime; + bool m_enabled = false; + bool m_manualToggle = false; + bool m_activeAuto; + bool m_startAllowed = false; + bool m_initialized = false; + QTimer m_startCooldown; + int m_temp; + double m_fadeDuration = 0.4; + bool m_useNativeNightlight = false; + QString m_pluginPath; + bool m_pluginLoadAttempted = false; + bool m_pluginLoadedByUs = false; + bool m_animating = false; + int m_lastTemp = 6500; + int m_currentTemp = 6500; + QProcess m_process; + QTimer m_timer; + QTimer m_manualTimer; + QLocalSocket m_eventSocket; + QByteArray m_socketBuffer; + QTimer m_reconnectTimer; + + void start(); + void end(); + [[nodiscard]] QString hyprlandDir() const; + [[nodiscard]] QString eventSocketPath() const; + [[nodiscard]] QString resolvePluginPath() const; + void tryLoadPlugin(); + void bootstrapState(); + void connectEventSocket(); + void scheduleReconnect(); + void handleEventLines(); + void handleEventLine(const QByteArray& line); + void updateState(bool enabled, bool animating, int last, int current); +}; + +}; // namespace ZShell::services