packages feed

taffybar-5.2.0: src/System/Taffybar/Information/Wlsunset.hs

-----------------------------------------------------------------------------

-----------------------------------------------------------------------------

-- |
-- Module      : System.Taffybar.Information.Wlsunset
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- This module provides process-level management of @wlsunset@, a
-- Wayland day\/night gamma adjustor. It polls for the running state of
-- the process and tracks mode cycling (auto → forced high temp →
-- forced low temp → auto) via @SIGUSR1@.
module System.Taffybar.Information.Wlsunset
  ( -- * Types
    WlsunsetMode (..),
    WlsunsetState (..),
    WlsunsetConfig (..),

    -- * State access
    getWlsunsetChan,
    getWlsunsetState,

    -- * Actions
    cycleWlsunsetMode,
    startWlsunset,
    stopWlsunset,
    toggleWlsunset,
    restartWlsunsetWithTemps,
  )
where

import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception.Enclosed (catchAny)
import Control.Monad (void, when)
import Control.Monad.IO.Class
import Control.Monad.STM (atomically)
import Control.Monad.Trans.Class
import Data.Default (Default (..))
import Data.List (isPrefixOf)
import System.Log.Logger
import System.Posix.Signals (sigUSR1, signalProcess)
import System.Posix.Types (CPid (..))
import System.Process (readProcess, spawnCommand)
import System.Taffybar.Context
import System.Taffybar.Information.Wakeup (getWakeupChannelSeconds)
import System.Taffybar.Util (logPrintF)
import Text.Read (readMaybe)

-- | The three operating modes that wlsunset cycles through when it
-- receives @SIGUSR1@.  The cycle order matches wlsunset's internal
-- ring: Auto → ForcedHighTemp → ForcedLowTemp → Auto.
data WlsunsetMode
  = -- | Normal day/night schedule
    WlsunsetAuto
  | -- | Forced high (day) temperature — no color shift
    WlsunsetForcedHighTemp
  | -- | Forced low (night) temperature — warm/reddish colors
    WlsunsetForcedLowTemp
  deriving (Eq, Show, Ord, Enum, Bounded)

-- | Observable state of the wlsunset process.
data WlsunsetState = WlsunsetState
  { wlsunsetRunning :: Bool,
    wlsunsetMode :: WlsunsetMode,
    -- | The effective high temperature wlsunset is running with.
    wlsunsetEffectiveHighTemp :: Int,
    -- | The effective low temperature wlsunset is running with.
    wlsunsetEffectiveLowTemp :: Int
  }
  deriving (Eq, Show)

-- | Configuration for the wlsunset monitor.
data WlsunsetConfig = WlsunsetConfig
  { -- | Full shell command used to start wlsunset (e.g.
    -- @\"wlsunset -l 38.9 -L -77.0\"@).
    wlsunsetCommand :: String,
    -- | High (day) temperature in Kelvin. Used for display only.
    -- Should match the @-T@ flag passed to wlsunset (default 6500).
    wlsunsetHighTemp :: Int,
    -- | Low (night) temperature in Kelvin. Used for display only.
    -- Should match the @-t@ flag passed to wlsunset (default 4000).
    wlsunsetLowTemp :: Int,
    -- | How often (in seconds) to poll for process status.
    wlsunsetPollIntervalSec :: Int
  }
  deriving (Eq, Show)

instance Default WlsunsetConfig where
  def =
    WlsunsetConfig
      { wlsunsetCommand = "wlsunset",
        wlsunsetHighTemp = 6500,
        wlsunsetLowTemp = 4000,
        wlsunsetPollIntervalSec = 2
      }

-- | Internal state bundle stored in 'contextState' via 'getStateDefault'.
newtype WlsunsetChanVar
  = WlsunsetChanVar (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig)

wlsunsetLogPath :: String
wlsunsetLogPath = "System.Taffybar.Information.Wlsunset"

wlsunsetLog :: (MonadIO m) => Priority -> String -> m ()
wlsunsetLog priority = liftIO . logM wlsunsetLogPath priority

wlsunsetLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
wlsunsetLogF = logPrintF wlsunsetLogPath

-- ---------------------------------------------------------------------------
-- Process helpers
-- ---------------------------------------------------------------------------

-- | Check whether wlsunset is running by calling @pgrep -x wlsunset@.
-- Returns a list of matching PIDs (empty when not running).
pgrepWlsunset :: IO [CPid]
pgrepWlsunset =
  (parsePids <$> readProcess "pgrep" ["-x", "wlsunset"] "")
    `catchAny` (\_ -> return [])
  where
    parsePids = map (CPid . fromIntegral) . concatMap toList . lines
    toList s = case (readMaybe s :: Maybe Int) of
      Just n -> [n]
      Nothing -> []

-- | Send @SIGUSR1@ to a wlsunset process to cycle its mode.
sendUSR1 :: CPid -> IO ()
sendUSR1 = signalProcess sigUSR1

-- ---------------------------------------------------------------------------
-- State management
-- ---------------------------------------------------------------------------

getWlsunsetChanVar :: WlsunsetConfig -> TaffyIO WlsunsetChanVar
getWlsunsetChanVar cfg =
  getStateDefault $ WlsunsetChanVar <$> monitorWlsunset cfg

-- | Get a broadcast channel that receives 'WlsunsetState' updates.
getWlsunsetChan :: WlsunsetConfig -> TaffyIO (TChan WlsunsetState)
getWlsunsetChan cfg = do
  WlsunsetChanVar (chan, _, _) <- getWlsunsetChanVar cfg
  return chan

-- | Get the current 'WlsunsetState'.
getWlsunsetState :: WlsunsetConfig -> TaffyIO WlsunsetState
getWlsunsetState cfg = do
  WlsunsetChanVar (_, var, _) <- getWlsunsetChanVar cfg
  lift $ readMVar var

-- | Start the polling loop that monitors wlsunset.
monitorWlsunset ::
  WlsunsetConfig ->
  TaffyIO (TChan WlsunsetState, MVar WlsunsetState, WlsunsetConfig)
monitorWlsunset cfg = do
  let initialState =
        WlsunsetState
          { wlsunsetRunning = False,
            wlsunsetMode = WlsunsetAuto,
            wlsunsetEffectiveHighTemp = wlsunsetHighTemp cfg,
            wlsunsetEffectiveLowTemp = wlsunsetLowTemp cfg
          }
  stateVar <- liftIO $ newMVar initialState
  chan <- liftIO newBroadcastTChanIO
  wakeupChan <- getWakeupChannelSeconds (max 1 (wlsunsetPollIntervalSec cfg))
  ourWakeupChan <- liftIO $ atomically $ dupTChan wakeupChan
  taffyFork $ do
    wlsunsetLog DEBUG "Starting wlsunset polling loop"
    let loop = do
          liftIO $ pollWlsunset chan stateVar
          liftIO $ void $ atomically $ readTChan ourWakeupChan
          loop
    loop
  return (chan, stateVar, cfg)

-- | A single poll iteration: check process status, update state, and
-- broadcast if changed.
pollWlsunset :: TChan WlsunsetState -> MVar WlsunsetState -> IO ()
pollWlsunset chan var = do
  pids <- pgrepWlsunset
  let isRunning = not (null pids)
  modifyMVar_ var $ \old -> do
    let wasRunning = wlsunsetRunning old
        -- When the process freshly appears, reset mode to Auto.
        newMode
          | not wasRunning && isRunning = WlsunsetAuto
          | not isRunning = WlsunsetAuto
          | otherwise = wlsunsetMode old
        new =
          old
            { wlsunsetRunning = isRunning,
              wlsunsetMode = newMode
            }
    when (new /= old) $ do
      wlsunsetLogF DEBUG "Wlsunset state changed: %s" new
      atomically $ writeTChan chan new
    return new

-- ---------------------------------------------------------------------------
-- Actions
-- ---------------------------------------------------------------------------

-- | Cycle wlsunset mode by sending @SIGUSR1@ to the process.
-- The mode cycles: Auto → ForcedHighTemp → ForcedLowTemp → Auto.
cycleWlsunsetMode :: WlsunsetConfig -> TaffyIO ()
cycleWlsunsetMode cfg = do
  WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg
  liftIO $ do
    pids <- pgrepWlsunset
    case pids of
      [] -> wlsunsetLog DEBUG "cycleWlsunsetMode: wlsunset not running"
      _ -> do
        mapM_ sendUSR1 pids
        modifyMVar_ var $ \old -> do
          let newMode = case wlsunsetMode old of
                WlsunsetAuto -> WlsunsetForcedHighTemp
                WlsunsetForcedHighTemp -> WlsunsetForcedLowTemp
                WlsunsetForcedLowTemp -> WlsunsetAuto
              new = old {wlsunsetMode = newMode}
          wlsunsetLogF DEBUG "Cycled wlsunset mode: %s" newMode
          atomically $ writeTChan chan new
          return new

-- | Start the wlsunset process using the configured command.
startWlsunset :: WlsunsetConfig -> TaffyIO ()
startWlsunset cfg = liftIO $ do
  wlsunsetLog DEBUG $ "Starting wlsunset: " ++ wlsunsetCommand cfg
  void $ spawnCommand (wlsunsetCommand cfg)

-- | Stop wlsunset by sending @SIGTERM@ (signal 15) to all instances.
stopWlsunset :: WlsunsetConfig -> TaffyIO ()
stopWlsunset _cfg = liftIO $ do
  pids <- pgrepWlsunset
  case pids of
    [] -> wlsunsetLog DEBUG "stopWlsunset: wlsunset not running"
    _ -> do
      wlsunsetLog DEBUG "Stopping wlsunset (SIGTERM)"
      mapM_ (signalProcess 15) pids

-- | Toggle wlsunset: stop it if running, start it if not.
toggleWlsunset :: WlsunsetConfig -> TaffyIO ()
toggleWlsunset cfg = do
  st <- getWlsunsetState cfg
  if wlsunsetRunning st
    then stopWlsunset cfg
    else startWlsunset cfg

-- | Restart wlsunset with specific low and high temperatures.
-- Kills the running instance, updates the effective temperatures in state,
-- and spawns a new instance with @-t lowTemp -T highTemp@.
restartWlsunsetWithTemps :: WlsunsetConfig -> Int -> Int -> TaffyIO ()
restartWlsunsetWithTemps cfg lowTemp highTemp = do
  WlsunsetChanVar (chan, var, _) <- getWlsunsetChanVar cfg
  liftIO $ do
    pids <- pgrepWlsunset
    mapM_ (signalProcess 15) pids
    let cmd = buildCommandWithTemps (wlsunsetCommand cfg) lowTemp highTemp
    wlsunsetLog DEBUG $ "Restarting wlsunset: " ++ cmd
    void $ spawnCommand cmd
    modifyMVar_ var $ \old -> do
      let new =
            old
              { wlsunsetMode = WlsunsetAuto,
                wlsunsetEffectiveHighTemp = highTemp,
                wlsunsetEffectiveLowTemp = lowTemp
              }
      atomically $ writeTChan chan new
      return new

-- ---------------------------------------------------------------------------
-- Command building
-- ---------------------------------------------------------------------------

-- | Build a wlsunset command with specific temperature flags.
-- Strips any existing @-t@\/@-T@ flags from the base command and appends
-- new ones.
buildCommandWithTemps :: String -> Int -> Int -> String
buildCommandWithTemps baseCmd lowTemp highTemp =
  unwords (stripTempArgs (words baseCmd))
    ++ " -t "
    ++ show lowTemp
    ++ " -T "
    ++ show highTemp

-- | Strip @-t \<val\>@ and @-T \<val\>@ arguments from a word list.
stripTempArgs :: [String] -> [String]
stripTempArgs [] = []
stripTempArgs ("-t" : _ : rest) = stripTempArgs rest
stripTempArgs ("-T" : _ : rest) = stripTempArgs rest
stripTempArgs (x : rest)
  | "-t" `isPrefixOf` x && length x > 2 = stripTempArgs rest
  | "-T" `isPrefixOf` x && length x > 2 = stripTempArgs rest
  | otherwise = x : stripTempArgs rest