packages feed

taffybar-7.2.0: src/System/Taffybar/Information/Hyprland.hs

{-# LANGUAGE OverloadedStrings #-}

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

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

-- |
-- Module      : System.Taffybar.Information.Hyprland
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- A small Hyprland "client" that provides a structured interface for issuing
-- Hyprland commands (optionally over the Hyprland command socket) and for
-- connecting to the Hyprland event socket.
--
-- This module is intended to centralize the socket/path logic so widgets can
-- share it. It is not (yet) wired into widgets.
module System.Taffybar.Information.Hyprland
  ( -- * Client
    HyprlandClient,
    HyprlandClientConfig (..),
    defaultHyprlandClientConfig,
    newHyprlandClient,
    reloadHyprlandClient,
    HyprlandClientEnv (..),
    getHyprlandClientEnv,

    -- * Hyprland Monad
    HyprlandT,
    runHyprlandT,
    askHyprlandClient,
    runHyprlandCommandRawM,
    runHyprlandCommandJsonM,

    -- * Shared Event Channel
    HyprlandEventChan (..),
    subscribeHyprlandEvents,
    buildHyprlandEventChan,

    -- * Commands
    HyprlandCommand (..),
    hyprCommand,
    hyprCommandJson,
    hyprlandCommandToSocketCommand,
    runHyprlandCommandRaw,
    runHyprlandCommandJson,

    -- * Sockets
    HyprlandSocket (..),
    hyprlandSocketName,
    hyprlandSocketPaths,
    openHyprlandSocket,
    openHyprlandEventSocket,
    withHyprlandEventSocket,

    -- * Monitor queries
    getFocusedMonitorPosition,

    -- * Errors
    HyprlandError (..),
  )
where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM.TChan
  ( TChan,
    dupTChan,
    newBroadcastTChanIO,
    writeTChan,
  )
import Control.Exception.Enclosed (catchAny)
import Control.Monad (forM, forever, void)
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.STM (atomically)
import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
import Data.Aeson (FromJSON (..), eitherDecode', withObject, (.:))
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BL
import Data.List (sortOn)
import Data.Ord (Down (..))
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Network.Socket as NS
import qualified Network.Socket.ByteString as NSB
import System.Directory (doesDirectoryExist, listDirectory)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO
  ( BufferMode (LineBuffering),
    Handle,
    IOMode (ReadWriteMode),
    hClose,
    hGetLine,
    hSetBuffering,
  )
import System.Log.Logger (Priority (..), logM)
import System.Posix.Files (getFileStatus, isSocket, modificationTime)
import System.Posix.Types (EpochTime)
import System.Taffybar.Util (runCommand)
import Text.Printf (printf)

-- | Errors that can occur while talking to Hyprland.
data HyprlandError
  = HyprlandEnvMissing String
  | HyprlandSocketUnavailable HyprlandSocket [FilePath]
  | HyprlandSocketException FilePath String
  | HyprlandHyprctlFailed String
  | HyprlandJsonDecodeFailed String
  | HyprlandCommandBuildFailed String
  deriving (Show, Eq)

-- | Configuration for creating and using a 'HyprlandClient'.
data HyprlandClientConfig = HyprlandClientConfig
  { -- | Try the Hyprland command/event sockets when possible.
    useSocket :: Bool,
    -- | If the socket path is unavailable, fall back to invoking @hyprctl@.
    fallbackToHyprctl :: Bool,
    hyprctlPath :: FilePath
  }
  deriving (Show, Eq)

-- | Default 'HyprlandClientConfig'.
--
-- Uses sockets when available and falls back to @hyprctl@.
defaultHyprlandClientConfig :: HyprlandClientConfig
defaultHyprlandClientConfig =
  HyprlandClientConfig
    { useSocket = True,
      fallbackToHyprctl = True,
      hyprctlPath = "hyprctl"
    }

-- | Environment-derived values used for socket path resolution.
data HyprlandClientEnv = HyprlandClientEnv
  { instanceSignature :: String,
    runtimeDir :: Maybe FilePath
  }
  deriving (Show, Eq)

-- | Read Hyprland-specific values from the process environment.
getHyprlandClientEnv :: IO (Either HyprlandError HyprlandClientEnv)
getHyprlandClientEnv = do
  mSig <- lookupEnv "HYPRLAND_INSTANCE_SIGNATURE"
  case mSig of
    Nothing ->
      return $ Left $ HyprlandEnvMissing "HYPRLAND_INSTANCE_SIGNATURE"
    Just sig -> do
      mRuntime <- lookupEnv "XDG_RUNTIME_DIR"
      return $
        Right $
          HyprlandClientEnv
            { instanceSignature = sig,
              runtimeDir = mRuntime
            }

-- | Runtime client state used by command and event helpers.
data HyprlandClient = HyprlandClient
  { clientConfig :: HyprlandClientConfig,
    clientEnv :: Maybe HyprlandClientEnv
  }
  deriving (Show, Eq)

-- | Construct a 'HyprlandClient' by reading environment variables.
--
-- If 'HYPRLAND_INSTANCE_SIGNATURE' is not set, socket-based operations will be
-- unavailable, but command execution may still succeed via the @hyprctl@
-- fallback depending on 'HyprlandClientConfig'.
newHyprlandClient :: HyprlandClientConfig -> IO HyprlandClient
newHyprlandClient cfg = do
  envResult <- getHyprlandClientEnv
  let env = either (const Nothing) Just envResult
  pure $ HyprlandClient {clientConfig = cfg, clientEnv = env}

-- | Reload the environment-derived portions of a 'HyprlandClient'.
reloadHyprlandClient :: HyprlandClient -> IO HyprlandClient
reloadHyprlandClient client = newHyprlandClient (clientConfig client)

-- | A reader transformer that carries a 'HyprlandClient'.
type HyprlandT m a = ReaderT HyprlandClient m a

-- | Run a 'HyprlandT' computation with the provided client.
runHyprlandT :: HyprlandClient -> HyprlandT m a -> m a
runHyprlandT = flip runReaderT

-- | Get the current 'HyprlandClient' from 'HyprlandT'.
askHyprlandClient :: (Monad m) => HyprlandT m HyprlandClient
askHyprlandClient = ask

-- | Run a command in 'HyprlandT' and return the raw bytes response.
runHyprlandCommandRawM :: (MonadIO m) => HyprlandCommand -> HyprlandT m (Either HyprlandError BS.ByteString)
runHyprlandCommandRawM cmd = do
  client <- ask
  liftIO $ runHyprlandCommandRaw client cmd

-- | Run a command in 'HyprlandT' and decode the response as JSON.
runHyprlandCommandJsonM :: (MonadIO m, FromJSON a) => HyprlandCommand -> HyprlandT m (Either HyprlandError a)
runHyprlandCommandJsonM cmd = do
  client <- ask
  liftIO $ runHyprlandCommandJson client cmd

-- | Which Hyprland socket should be used for a request.
data HyprlandSocket
  = HyprlandCommandSocket
  | HyprlandEventSocket
  deriving (Show, Eq)

-- | Socket file basename for a given 'HyprlandSocket' kind.
hyprlandSocketName :: HyprlandSocket -> FilePath
hyprlandSocketName HyprlandCommandSocket = ".socket.sock"
hyprlandSocketName HyprlandEventSocket = ".socket2.sock"

-- | Candidate socket paths for a client and socket type.
hyprlandSocketPaths :: HyprlandClient -> HyprlandSocket -> [FilePath]
hyprlandSocketPaths HyprlandClient {clientEnv = Nothing} _ = []
hyprlandSocketPaths
  HyprlandClient
    { clientEnv =
        Just
          HyprlandClientEnv
            { instanceSignature = sig,
              runtimeDir = mRuntimeDir
            }
    }
  sock =
    let name = hyprlandSocketName sock
        runtimePaths =
          case mRuntimeDir of
            Nothing -> []
            Just rd -> [rd ++ "/hypr/" ++ sig ++ "/" ++ name]
        tmpPath = "/tmp/hypr/" ++ sig ++ "/" ++ name
     in runtimePaths ++ [tmpPath]

-- | Open a Hyprland unix socket, trying known and discovered locations.
openHyprlandSocket :: HyprlandClient -> HyprlandSocket -> IO (Either HyprlandError NS.Socket)
openHyprlandSocket client sock = do
  -- Prefer the instance signature from the process environment first. If that
  -- fails (e.g. Hyprland restarted and the signature changed), fall back to
  -- discovering currently-running instance sockets from the filesystem.
  let envPaths = hyprlandSocketPaths client sock
  envResult <- connectFirst envPaths
  case envResult of
    Just s -> pure (Right s)
    Nothing -> do
      discoveredPaths <- discoverHyprlandSocketPaths client sock
      discResult <- connectFirst discoveredPaths
      case discResult of
        Just s -> pure (Right s)
        Nothing ->
          if null envPaths && null discoveredPaths
            then pure $ Left $ HyprlandEnvMissing "HYPRLAND_INSTANCE_SIGNATURE"
            else pure $ Left $ HyprlandSocketUnavailable sock (envPaths ++ discoveredPaths)
  where
    connectFirst :: [FilePath] -> IO (Maybe NS.Socket)
    connectFirst [] = pure Nothing
    connectFirst (path : rest) = do
      result <- connectToSocket path
      case result of
        Left _ -> connectFirst rest
        Right s -> pure (Just s)

discoverHyprlandSocketPaths :: HyprlandClient -> HyprlandSocket -> IO [FilePath]
discoverHyprlandSocketPaths _client sock = do
  mRuntime <- lookupEnv "XDG_RUNTIME_DIR"
  let bases =
        maybe [] (\rd -> [rd </> "hypr"]) mRuntime
          ++ ["/tmp/hypr"]
  let name = hyprlandSocketName sock
  pathsWithTimes <- fmap concat $ forM bases $ \base -> do
    baseExists <- doesDirectoryExist base
    if not baseExists
      then pure []
      else do
        -- Best-effort: Hyprland instances come and go and we don't want to
        -- bring down widgets if this scan races a restart.
        sigEntries <- listDirectory base `catchAny` \_ -> pure []
        let candidates = map (\sig -> base </> sig </> name) sigEntries
        fmap concat $ forM candidates $ \p -> do
          mTime <- socketPathMTime p
          pure $ maybe [] (\t -> [(t, p)]) mTime

  -- Prefer the newest sockets first to minimize binding to a stale instance.
  pure $ map snd $ sortOn (Down . fst) pathsWithTimes

socketPathMTime :: FilePath -> IO (Maybe EpochTime)
socketPathMTime path =
  ( do
      st <- getFileStatus path
      if isSocket st
        then pure $ Just $ modificationTime st
        else pure Nothing
  )
    `catchAny` \_ -> pure Nothing

connectToSocket :: FilePath -> IO (Either HyprlandError NS.Socket)
connectToSocket path = do
  sock <- NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol
  ( do
      NS.connect sock (NS.SockAddrUnix path)
      return $ Right sock
    )
    `catchAny` \e -> do
      void $ NS.close sock `catchAny` \_ -> pure ()
      return $ Left $ HyprlandSocketException path (show e)

-- | Open the Hyprland event socket and return it as a line-buffered 'Handle'.
openHyprlandEventSocket :: HyprlandClient -> IO (Either HyprlandError Handle)
openHyprlandEventSocket client = do
  sockResult <- openHyprlandSocket client HyprlandEventSocket
  case sockResult of
    Left err -> return (Left err)
    Right sock -> do
      handle <- NS.socketToHandle sock ReadWriteMode
      hSetBuffering handle LineBuffering
      return (Right handle)

-- | Bracket-style helper for safely using the Hyprland event socket handle.
withHyprlandEventSocket :: HyprlandClient -> (Handle -> IO a) -> IO (Either HyprlandError a)
withHyprlandEventSocket client action = do
  handleResult <- openHyprlandEventSocket client
  case handleResult of
    Left err -> return (Left err)
    Right handle ->
      ( do
          result <- action handle
          hClose handle
          return (Right result)
      )
        `catchAny` \e -> do
          hClose handle
          return (Left $ HyprlandSocketException (show HyprlandEventSocket) (show e))

-- | A shared broadcast channel for Hyprland events read from the event socket.
--
-- Readers should call 'subscribeHyprlandEvents' to get their own cursor.
newtype HyprlandEventChan
  = HyprlandEventChan (TChan T.Text)

-- | Subscribe to a per-reader cursor for a shared Hyprland event channel.
subscribeHyprlandEvents :: HyprlandEventChan -> IO (TChan T.Text)
subscribeHyprlandEvents (HyprlandEventChan chan) =
  atomically $ dupTChan chan

-- | Create and maintain a shared Hyprland event broadcast channel.
buildHyprlandEventChan :: HyprlandClient -> IO HyprlandEventChan
buildHyprlandEventChan client = do
  chan <- newBroadcastTChanIO
  _ <- forkIO $ eventThread chan
  pure $ HyprlandEventChan chan
  where
    logH :: Priority -> String -> IO ()
    logH = logM "System.Taffybar.Information.Hyprland"

    retryDelayMicros :: Int
    retryDelayMicros = 1 * 1000000

    eventThread chan = forever $ do
      handleResult <- openHyprlandEventSocket client
      case handleResult of
        Left err -> do
          logH WARNING $ printf "Hyprland event socket unavailable: %s" (show err)
          threadDelay retryDelayMicros
        Right handle -> do
          -- Emit a synthetic event on (re)connect so widgets can refresh their
          -- state after Hyprland restarts without resorting to polling.
          atomically $ writeTChan chan "taffybar-hyprland-connected>>"
          let loop =
                hGetLine handle >>= (atomically . writeTChan chan . T.pack) >> loop
          loop `catchAny` \e ->
            logH WARNING $ printf "Hyprland event socket failed: %s" (show e)
          void $ hClose handle `catchAny` \_ -> pure ()
          threadDelay retryDelayMicros

-- | Minimal monitor info parsed from @hyprctl monitors -j@.
data HyprMonitorInfo = HyprMonitorInfo
  { hmX :: Int,
    hmY :: Int,
    hmFocused :: Bool
  }

instance FromJSON HyprMonitorInfo where
  parseJSON = withObject "HyprMonitorInfo" $ \o ->
    HyprMonitorInfo
      <$> o .: "x"
      <*> o .: "y"
      <*> o .: "focused"

-- | Get the global coordinates of the currently focused Hyprland monitor.
getFocusedMonitorPosition :: HyprlandClient -> IO (Maybe (Int, Int))
getFocusedMonitorPosition client = do
  result <- runHyprlandCommandJson client (hyprCommandJson ["monitors"])
  case result of
    Left _ -> return Nothing
    Right monitors ->
      case filter hmFocused monitors of
        (m : _) -> return $ Just (hmX m, hmY m)
        [] -> return Nothing

-- | Structured representation of a Hyprland command invocation.
data HyprlandCommand = HyprlandCommand
  { commandArgs :: [String],
    commandJson :: Bool
  }
  deriving (Show, Eq)

-- | Construct a non-JSON command.
hyprCommand :: [String] -> HyprlandCommand
hyprCommand args = HyprlandCommand {commandArgs = args, commandJson = False}

-- | Construct a command whose output should be JSON.
hyprCommandJson :: [String] -> HyprlandCommand
hyprCommandJson args = HyprlandCommand {commandArgs = args, commandJson = True}

-- | Encode a 'HyprlandCommand' into the bytes expected by the command socket.
hyprlandCommandToSocketCommand :: HyprlandCommand -> Either HyprlandError BS.ByteString
hyprlandCommandToSocketCommand HyprlandCommand {commandArgs = args, commandJson = isJson}
  | null args = Left $ HyprlandCommandBuildFailed "No Hyprland command provided"
  | isJson = Right $ TE.encodeUtf8 $ T.pack $ "j/" ++ unwords args
  | otherwise = Right $ TE.encodeUtf8 $ T.pack $ unwords args

-- | Run a Hyprland command, preferring the command socket if enabled.
--
-- If socket execution fails and 'fallbackToHyprctl' is enabled, @hyprctl@ will
-- be invoked as a fallback.
runHyprlandCommandRaw :: HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError BS.ByteString)
runHyprlandCommandRaw
  client@HyprlandClient
    { clientConfig =
        HyprlandClientConfig
          { useSocket = useSock,
            fallbackToHyprctl = fallback,
            hyprctlPath = hyprctl
          }
    }
  cmd = do
    socketResult <-
      if useSock
        then runHyprlandCommandSocket client cmd
        else pure $ Left $ HyprlandSocketUnavailable HyprlandCommandSocket []
    case socketResult of
      Right out -> pure (Right out)
      Left sockErr ->
        if fallback
          then do
            hyprctlResult <- runHyprlandCommandHyprctl client hyprctl cmd
            pure $ case hyprctlResult of
              Right out -> Right out
              Left hyprctlErr ->
                Left $
                  HyprlandHyprctlFailed $
                    printf "%s (socket error: %s)" hyprctlErr (show sockErr)
          else pure (Left sockErr)

runHyprlandCommandSocket :: HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError BS.ByteString)
runHyprlandCommandSocket client cmd = do
  sockResult <- openHyprlandSocket client HyprlandCommandSocket
  case sockResult of
    Left err -> pure (Left err)
    Right sock ->
      ( do
          cmdBytes <- case hyprlandCommandToSocketCommand cmd of
            Left e -> NS.close sock >> pure (Left e)
            Right b -> pure (Right b)
          case cmdBytes of
            Left e -> pure (Left e)
            Right b -> do
              NSB.sendAll sock b
              NS.shutdown sock NS.ShutdownSend
              resp <- recvAll sock
              NS.close sock
              pure (Right resp)
      )
        `catchAny` \e -> do
          NS.close sock
          pure $ Left $ HyprlandSocketException (show HyprlandCommandSocket) (show e)

runHyprlandCommandHyprctl :: HyprlandClient -> FilePath -> HyprlandCommand -> IO (Either String BS.ByteString)
runHyprlandCommandHyprctl client hyprctl HyprlandCommand {commandArgs = args, commandJson = isJson} = do
  mSig <- pickHyprlandInstanceSignature client
  let flags =
        ["-j" | isJson]
          ++ maybe [] (\sig -> ["-i", sig]) mSig
  result <- runCommand hyprctl (flags ++ args)
  pure $ case result of
    Left err -> Left err
    Right out -> Right $ TE.encodeUtf8 $ T.pack out

pickHyprlandInstanceSignature :: HyprlandClient -> IO (Maybe String)
pickHyprlandInstanceSignature client =
  case clientEnv client of
    Just HyprlandClientEnv {instanceSignature = sig} -> do
      -- Prefer the signature from the environment if we can actually connect
      -- to the command socket. (After a Hyprland restart the old socket path
      -- might still exist but be stale.)
      envAlive <-
        anyM canConnectSocket $
          hyprlandSocketPaths client HyprlandCommandSocket
      if envAlive
        then pure (Just sig)
        else discover
    Nothing -> discover
  where
    canConnectSocket path = do
      result <- connectToSocket path
      case result of
        Left _ -> pure False
        Right sock -> do
          void $ NS.close sock `catchAny` \_ -> pure ()
          pure True

    discover = do
      -- Use the newest discovered command socket.
      paths <- discoverHyprlandSocketPaths client HyprlandCommandSocket
      pure $ case paths of
        p : _ -> Just $ takeFileName $ takeDirectory p
        [] -> Nothing

anyM :: (Monad m) => (a -> m Bool) -> [a] -> m Bool
anyM _ [] = pure False
anyM p (x : xs) = do
  b <- p x
  if b then pure True else anyM p xs

recvAll :: NS.Socket -> IO BS.ByteString
recvAll sock = go []
  where
    go acc = do
      chunk <- NSB.recv sock 4096
      if BS.null chunk
        then return (BS.concat (reverse acc))
        else go (chunk : acc)

-- | Run a command and decode the response body as JSON.
runHyprlandCommandJson :: (FromJSON a) => HyprlandClient -> HyprlandCommand -> IO (Either HyprlandError a)
runHyprlandCommandJson client cmd = do
  raw <- runHyprlandCommandRaw client cmd
  pure $ case raw of
    Left err -> Left err
    Right out ->
      case eitherDecode' (BL.fromStrict out) of
        Left decodeErr -> Left $ HyprlandJsonDecodeFailed decodeErr
        Right a -> Right a