#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::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); for (size_t i = 0; i < 3; ++i) g_currentGain[i] = g_transition.fromGain[i] + (g_transition.toGain[i] - g_transition.fromGain[i]) * t; if (g_uniformMode) { setUniformGain(g_currentGain); } else { const int step = static_cast(t * 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() {}