packages feed

taffybar-4.1.2: src/System/Taffybar/Information/PulseAudio.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

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

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

-- |
-- Module      : System.Taffybar.Information.PulseAudio
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- Basic PulseAudio information using PulseAudio's DBus interface.
--
-- Note: PulseAudio's DBus socket is not always enabled by default.
-- If callers see 'Nothing' from 'connectPulseAudio' (or 'getPulseAudioInfo'
-- returns 'Nothing'), ensure the PulseAudio server has loaded
-- @module-dbus-protocol@
-- so @/run/user/$UID/pulse/dbus-socket@ exists.
module System.Taffybar.Information.PulseAudio
  ( PulseAudioInfo (..),
    getPulseAudioInfo,
    getPulseAudioInfoFromClient,
    getPulseAudioInfoChan,
    getPulseAudioInfoState,
    getPulseAudioInfoChanAndVar,
    getPulseAudioInfoChanFor,
    getPulseAudioInfoStateFor,
    connectPulseAudio,
    togglePulseAudioMute,
    adjustPulseAudioVolume,
  )
where

import Control.Applicative ((<|>))
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, finally, throwIO, try)
import Control.Monad (forever, guard, join, void, when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.STM (atomically)
import Control.Monad.Trans.Except
import DBus
import DBus.Client
import DBus.Internal.Types (Serial (..))
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.List (isInfixOf)
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, isNothing, listToMaybe)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Word (Word32)
import GHC.TypeLits (KnownSymbol, SomeSymbol (..), Symbol, someSymbolVal, symbolVal)
import System.Directory (doesPathExist)
import System.Environment (lookupEnv)
import System.FilePath ((</>))
import System.Log.Logger (Priority (..))
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.DBus.Client.Params
  ( paCoreInterfaceName,
    paCoreObjectPath,
    paDeviceInterfaceName,
    paServerLookupBusName,
    paServerLookupInterfaceName,
    paServerLookupObjectPath,
  )
import System.Taffybar.Util (logPrintF, maybeToEither)

-- | PulseAudio information for the default (or provided) sink.
data PulseAudioInfo = PulseAudioInfo
  { pulseAudioVolumePercent :: Maybe Int,
    pulseAudioMuted :: Maybe Bool,
    pulseAudioSinkName :: String
  }
  deriving (Eq, Show)

audioLogPath :: String
audioLogPath = "System.Taffybar.Information.PulseAudio"

audioLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
audioLogF = logPrintF audioLogPath

newtype PulseAudioInfoChanVar (a :: Symbol)
  = PulseAudioInfoChanVar (TChan (Maybe PulseAudioInfo), MVar (Maybe PulseAudioInfo))

-- | Get a broadcast channel for PulseAudio info for the provided sink spec.
--
-- The first call for a given sink spec will start a monitoring thread that
-- keeps a PulseAudio DBus connection open and refreshes on property changes.
-- Subsequent calls return the already created channel.
getPulseAudioInfoChan :: String -> TaffyIO (TChan (Maybe PulseAudioInfo))
getPulseAudioInfoChan sinkSpec =
  case someSymbolVal sinkSpec of
    SomeSymbol (Proxy :: Proxy sym) -> getPulseAudioInfoChanFor @sym

-- | Read the current PulseAudio info state for the provided sink spec.
--
-- See 'getPulseAudioInfoChan' for monitoring behavior.
getPulseAudioInfoState :: String -> TaffyIO (Maybe PulseAudioInfo)
getPulseAudioInfoState sinkSpec =
  case someSymbolVal sinkSpec of
    SomeSymbol (Proxy :: Proxy sym) -> getPulseAudioInfoStateFor @sym

-- | Get both the broadcast channel and the state MVar for PulseAudio info.
--
-- This is useful when a widget needs to read the current state at realize time
-- (from the MVar) and also subscribe to future updates (via the channel).
getPulseAudioInfoChanAndVar :: String -> TaffyIO (TChan (Maybe PulseAudioInfo), MVar (Maybe PulseAudioInfo))
getPulseAudioInfoChanAndVar sinkSpec =
  case someSymbolVal sinkSpec of
    SomeSymbol (Proxy :: Proxy sym) -> do
      PulseAudioInfoChanVar (chan, var) <- getPulseAudioInfoChanVarFor @sym
      pure (chan, var)

-- | Get a broadcast channel for PulseAudio info for a sink spec given as a
-- type-level string.
--
-- This is the closest analogue to 'System.Taffybar.Information.Crypto', where
-- the type parameter provides a stable key for 'getStateDefault' so multiple
-- parameterized versions can coexist in 'Context'.
getPulseAudioInfoChanFor :: forall a. KnownSymbol a => TaffyIO (TChan (Maybe PulseAudioInfo))
getPulseAudioInfoChanFor = do
  PulseAudioInfoChanVar (chan, _) <- getPulseAudioInfoChanVarFor @a
  pure chan

-- | Read the current PulseAudio info state for a sink spec given as a
-- type-level string.
getPulseAudioInfoStateFor :: forall a. KnownSymbol a => TaffyIO (Maybe PulseAudioInfo)
getPulseAudioInfoStateFor = do
  PulseAudioInfoChanVar (_, var) <- getPulseAudioInfoChanVarFor @a
  liftIO $ readMVar var

getPulseAudioInfoChanVarFor :: forall a. KnownSymbol a => TaffyIO (PulseAudioInfoChanVar a)
getPulseAudioInfoChanVarFor =
  getStateDefault $ do
    let sinkSpec = symbolVal (Proxy @a)
    liftIO $ do
      chan <- newBroadcastTChanIO
      var <- newMVar Nothing
      _ <- forkIO $ monitorPulseAudioInfo sinkSpec chan var
      pure $ PulseAudioInfoChanVar (chan, var)

monitorPulseAudioInfo ::
  String ->
  TChan (Maybe PulseAudioInfo) ->
  MVar (Maybe PulseAudioInfo) ->
  IO ()
monitorPulseAudioInfo sinkSpec chan var = do
  refreshLock <- newMVar ()
  let writeInfo info = do
        _ <- swapMVar var info
        atomically $ writeTChan chan info

      refreshWithClientUnlocked client = do
        result <- try $ getPulseAudioInfoFromClient client sinkSpec
        case result of
          Left (e :: SomeException) -> do
            audioLogF WARNING "Audio refresh failed: %s" e
            writeInfo Nothing
            pure $ Left e
          Right info -> writeInfo info >> pure (Right info)

      -- PulseAudio's DBus interface is usually exposed via a peer-to-peer
      -- connection (unix:path=$XDG_RUNTIME_DIR/pulse/dbus-socket). In that mode
      -- there is no bus daemon, so org.freedesktop.DBus.AddMatch/RemoveMatch do
      -- not exist.
      --
      -- Instead, PulseAudio provides org.PulseAudio.Core1.ListenForSignal which
      -- must be called to enable emission of signals like:
      --   org.PulseAudio.Core1.Device.VolumeUpdated
      --   org.PulseAudio.Core1.Device.MuteUpdated
      --
      -- We still use haskell-dbus 'addMatch' to register local handlers. When
      -- AddMatch fails (peer-to-peer), we ignore the exception; the handler is
      -- registered locally before the failing bus call.

      isMissingAddMatch :: ClientError -> Bool
      isMissingAddMatch e =
        let msg = clientErrorMessage e
         in "AddMatch" `isInfixOf` msg
              && ("doesn't exist" `isInfixOf` msg || "UnknownMethod" `isInfixOf` msg)

      addMatchP2P :: Client -> MatchRule -> (Signal -> IO ()) -> IO (Maybe SignalHandler)
      addMatchP2P client rule callback = do
        result <- try (addMatch client rule callback)
        case result of
          Right handler -> pure (Just handler)
          Left (e :: ClientError)
            | isMissingAddMatch e -> pure Nothing
            | otherwise -> throwIO e

      listenForSignal :: Client -> String -> [ObjectPath] -> IO ()
      listenForSignal client signalName objects = do
        let callMsg =
              (methodCall paCorePath paCoreInterfaceName "ListenForSignal")
                { methodCallDestination = Nothing
                , methodCallBody = [toVariant signalName, toVariant objects]
                }
        reply <- call client callMsg
        case reply of
          Left err -> audioLogF WARNING "PulseAudio ListenForSignal failed: %s" err
          Right _ -> pure ()

      paCoreInterfaceNameStr :: String
      paCoreInterfaceNameStr = "org.PulseAudio.Core1"

      paDeviceInterfaceNameStr :: String
      paDeviceInterfaceNameStr = "org.PulseAudio.Core1.Device"

      volumeUpdatedSignal :: String
      volumeUpdatedSignal = paDeviceInterfaceNameStr ++ ".VolumeUpdated"

      muteUpdatedSignal :: String
      muteUpdatedSignal = paDeviceInterfaceNameStr ++ ".MuteUpdated"

      coreSignalName :: String -> String
      coreSignalName memberName = paCoreInterfaceNameStr ++ "." ++ memberName

      coreFallbackUpdatedMember :: MemberName
      coreFallbackUpdatedMember = "FallbackSinkUpdated"

      coreFallbackUnsetMember :: MemberName
      coreFallbackUnsetMember = "FallbackSinkUnset"

      coreNewSinkMember :: MemberName
      coreNewSinkMember = "NewSink"

      coreSinkRemovedMember :: MemberName
      coreSinkRemovedMember = "SinkRemoved"

      deviceSignalMatcher :: MemberName -> MatchRule
      deviceSignalMatcher memberName =
        matchAny
          { matchPathNamespace = Just paCoreObjectPath
          , matchInterface = Just paDeviceInterfaceName
          , matchMember = Just memberName
          }

      coreSignalMatcher :: MemberName -> MatchRule
      coreSignalMatcher memberName =
        matchAny
          { matchPath = Just paCoreObjectPath
          , matchInterface = Just paCoreInterfaceName
          , matchMember = Just memberName
          }

      loop = do
        mClient <- connectPulseAudio
        case mClient of
          Nothing -> do
            writeInfo Nothing
            -- No polling; just retry connection periodically.
            threadDelay 5000000
            loop
          Just client -> do
            let runWithClient = do
                  subscribedSinksRef <- newIORef ([] :: [ObjectPath])

                  let getSinksUnlocked = do
                        corePropsResult <- getProperties client Nothing paCorePath paCoreInterfaceName
                        case corePropsResult of
                          Left err -> do
                            audioLogF WARNING "Failed to read PulseAudio Core1 properties: %s" err
                            pure []
                          Right coreProps ->
                            pure $ fromMaybe [] (readDictMaybe coreProps "Sinks")

                      updateDeviceSignalSubscriptionsUnlocked = do
                        sinks <- getSinksUnlocked
                        prev <- readIORef subscribedSinksRef
                        when (sinks /= prev && not (null sinks)) $ do
                          writeIORef subscribedSinksRef sinks
                          listenForSignal client volumeUpdatedSignal sinks
                          listenForSignal client muteUpdatedSignal sinks

                      resubscribeAndRefresh = withMVar refreshLock $ \_ -> do
                        updateDeviceSignalSubscriptionsUnlocked
                        void $ refreshWithClientUnlocked client

                  -- Ask PulseAudio to emit the signals we care about.
                  listenForSignal client (coreSignalName "FallbackSinkUpdated") [paCorePath]
                  listenForSignal client (coreSignalName "FallbackSinkUnset") [paCorePath]
                  listenForSignal client (coreSignalName "NewSink") [paCorePath]
                  listenForSignal client (coreSignalName "SinkRemoved") [paCorePath]

                  -- Emit device signals for the current set of sinks.
                  resubscribeAndRefresh

                  -- Install signal handlers. In peer-to-peer mode this will
                  -- throw a ClientError due to missing AddMatch, but the handler
                  -- remains registered locally and still works.
                  hVol <- addMatchP2P client (deviceSignalMatcher "VolumeUpdated") $ const resubscribeAndRefresh
                  hMute <- addMatchP2P client (deviceSignalMatcher "MuteUpdated") $ const resubscribeAndRefresh
                  hFallbackUpdated <- addMatchP2P client (coreSignalMatcher coreFallbackUpdatedMember) $ const resubscribeAndRefresh
                  hFallbackUnset <- addMatchP2P client (coreSignalMatcher coreFallbackUnsetMember) $ const resubscribeAndRefresh
                  hNewSink <- addMatchP2P client (coreSignalMatcher coreNewSinkMember) $ const resubscribeAndRefresh
                  hSinkRemoved <- addMatchP2P client (coreSignalMatcher coreSinkRemovedMember) $ const resubscribeAndRefresh

                  let cleanup = do
                        let removeIfJust = maybe (pure ()) (removeMatch client)
                        removeIfJust hVol
                        removeIfJust hMute
                        removeIfJust hFallbackUpdated
                        removeIfJust hFallbackUnset
                        removeIfJust hNewSink
                        removeIfJust hSinkRemoved

                  blockForever `finally` cleanup
            result <- try runWithClient
            case result of
              Left (e :: SomeException) ->
                audioLogF WARNING "Audio monitor error: %s" e
              Right _ -> pure ()
            disconnect client
            loop

      -- Avoid BlockedIndefinitelyOnMVar exceptions from the "empty mvar trick".
      blockForever =
        forever $ threadDelay 1000000000 -- ~1000s; interruptible by async exceptions.

  loop

-- | Query volume and mute state for the provided sink name.
-- Returns Nothing when neither value is available.
getPulseAudioInfo :: String -> IO (Maybe PulseAudioInfo)
getPulseAudioInfo sinkSpec = do
  result <- withPulseAudio (`getPulseAudioInfoFromClient` sinkSpec)
  return $ join result

getPulseAudioInfoFromClient :: Client -> String -> IO (Maybe PulseAudioInfo)
getPulseAudioInfoFromClient client sinkSpec = do
  sinkPath <- resolveSinkPath client sinkSpec
  maybe (return Nothing) (getDeviceInfo client) sinkPath

-- | Toggle mute for the provided sink. Returns True on success.
togglePulseAudioMute :: String -> IO Bool
togglePulseAudioMute sinkSpec = do
  result <- withPulseAudio $ \client -> do
    sinkPath <- resolveSinkPath client sinkSpec
    case sinkPath of
      Nothing -> return False
      Just path -> do
        propsResult <- getProperties client Nothing path paDeviceInterfaceName
        case propsResult of
          Left err -> do
            audioLogF WARNING "Failed to read device properties: %s" err
            return False
          Right props -> case readDictMaybe props "Mute" of
            Nothing -> return False
            Just muted -> setDeviceMute client path (not muted)
  return $ fromMaybe False result

-- | Adjust volume by the provided percentage delta. Returns True on success.
adjustPulseAudioVolume :: String -> Int -> IO Bool
adjustPulseAudioVolume sinkSpec deltaPercent = do
  result <- withPulseAudio $ \client -> do
    sinkPath <- resolveSinkPath client sinkSpec
    case sinkPath of
      Nothing -> return False
      Just path -> do
        propsResult <- getProperties client Nothing path paDeviceInterfaceName
        case propsResult of
          Left err -> do
            audioLogF WARNING "Failed to read device properties: %s" err
            return False
          Right props -> do
            let maxVol = fromMaybe defaultMaxVolume (readDictMaybe props "MaxVolume")
            case readDictMaybe props "Volume" of
              Nothing -> return False
              Just volumes -> setDeviceVolume client path maxVol volumes deltaPercent
  return $ fromMaybe False result

-- --------------------------------------------------------------------------
-- DBus helpers

paServerLookupPath :: ObjectPath
paServerLookupPath = paServerLookupObjectPath

paCorePath :: ObjectPath
paCorePath = paCoreObjectPath

nullObjectPath :: ObjectPath
nullObjectPath = objectPath_ "/"

connectPulseAudio :: IO (Maybe Client)
connectPulseAudio = do
  addressString <- getPulseAudioAddress
  case addressString >>= parsePulseAddress of
    Nothing -> return Nothing
    Just addr -> do
      result <- try (connect addr)
      case result of
        Left (_ :: SomeException) -> return Nothing
        Right client -> return (Just client)

withPulseAudio :: (Client -> IO a) -> IO (Maybe a)
withPulseAudio action = do
  mClient <- connectPulseAudio
  case mClient of
    Nothing -> return Nothing
    Just client -> do
      result <- try $ action client
      disconnect client
      case result of
        Left (_ :: SomeException) -> return Nothing
        Right value -> return $ Just value

parsePulseAddress :: String -> Maybe Address
parsePulseAddress addrStr = listToMaybe =<< parseAddresses addrStr

getPulseAudioAddress :: IO (Maybe String)
getPulseAudioAddress = do
  envAddress <- lookupEnv "PULSE_DBUS_SERVER"
  case envAddress of
    Just addr | not (null addr) -> return (Just addr)
    _ -> do
      result <- try connectSession
      case result of
        Left (_ :: SomeException) -> runtimeDirFallback
        Right client -> do
          pulseAddr <- getServerLookupAddress client
          disconnect client
          case pulseAddr of
            Just _ -> return pulseAddr
            Nothing -> runtimeDirFallback

-- Many setups (notably pipewire-pulse) have the peer-to-peer PulseAudio DBus
-- socket available at $XDG_RUNTIME_DIR/pulse/dbus-socket but do not expose a
-- ServerLookup1 entry on the session bus. Fall back to the well-known socket.
runtimeDirFallback :: IO (Maybe String)
runtimeDirFallback = do
  mRuntimeDir <- lookupEnv "XDG_RUNTIME_DIR"
  case mRuntimeDir of
    Nothing -> return Nothing
    Just runtimeDir -> do
      let sockPath = runtimeDir </> "pulse" </> "dbus-socket"
      exists <- doesPathExist sockPath
      return $
        if exists
          then Just ("unix:path=" ++ sockPath)
          else Nothing

getServerLookupAddress :: Client -> IO (Maybe String)
getServerLookupAddress client = do
  propsResult <-
    getProperties
      client
      (Just paServerLookupBusName)
      paServerLookupPath
      paServerLookupInterfaceName
  case propsResult of
    Left err -> do
      audioLogF WARNING "Failed to read PulseAudio ServerLookup1 properties: %s" err
      return Nothing
    Right props -> case readDictMaybe props "Address" of
      Just addr | not (null addr) -> return (Just addr)
      _ -> return Nothing

resolveSinkPath :: Client -> String -> IO (Maybe ObjectPath)
resolveSinkPath client sinkSpec = do
  corePropsResult <- getProperties client Nothing paCorePath paCoreInterfaceName
  case corePropsResult of
    Left err -> do
      audioLogF WARNING "Failed to read PulseAudio Core1 properties: %s" err
      return Nothing
    Right coreProps
      | sinkSpec == "@DEFAULT_SINK@" || null sinkSpec ->
          return $ selectDefaultSink coreProps
      | isObjectPath sinkSpec ->
          return $ Just $ objectPath_ sinkSpec
      | otherwise -> findSinkByName client coreProps sinkSpec

selectDefaultSink :: Map Text Variant -> Maybe ObjectPath
selectDefaultSink props =
  let fallback = readDictMaybe props "FallbackSink" :: Maybe ObjectPath
      sinks = readDictMaybe props "Sinks" :: Maybe [ObjectPath]
   in case fallback of
        Just path | path /= nullObjectPath -> Just path
        _ -> listToMaybe =<< sinks

findSinkByName :: Client -> Map Text Variant -> String -> IO (Maybe ObjectPath)
findSinkByName client props target =
  case readDictMaybe props "Sinks" :: Maybe [ObjectPath] of
    Nothing -> return Nothing
    Just sinks -> findM (sinkMatchesName client target) sinks

sinkMatchesName :: Client -> String -> ObjectPath -> IO Bool
sinkMatchesName client target path = do
  propsResult <- getProperties client Nothing path paDeviceInterfaceName
  case propsResult of
    Left _ -> return False
    Right props ->
      let name = readDictMaybe props "Name" :: Maybe String
          description = readDictMaybe props "Description" :: Maybe String
       in return $ Just target == name || Just target == description

getDeviceInfo :: Client -> ObjectPath -> IO (Maybe PulseAudioInfo)
getDeviceInfo client path = do
  propsResult <- getProperties client Nothing path paDeviceInterfaceName
  case propsResult of
    Left err -> do
      audioLogF WARNING "Failed to read device properties: %s" err
      return Nothing
    Right props -> return $ Just $ buildPulseAudioInfo props

buildPulseAudioInfo :: Map Text Variant -> PulseAudioInfo
buildPulseAudioInfo props =
  let volumePercent = calcVolumePercent props
      muted = readDictMaybe props "Mute"
      name = fromMaybe "" (readDictMaybe props "Description" <|> readDictMaybe props "Name")
   in PulseAudioInfo
        { pulseAudioVolumePercent = volumePercent,
          pulseAudioMuted = muted,
          pulseAudioSinkName = name
        }

calcVolumePercent :: Map Text Variant -> Maybe Int
calcVolumePercent props = do
  volumes <- readDictMaybe props "Volume" :: Maybe [Word32]
  guard (not (null volumes))
  let maxVolume = fromMaybe defaultMaxVolume (readDictMaybe props "MaxVolume")
  guard (maxVolume > 0)
  let total = sum volumes
      avg = fromIntegral total / fromIntegral (length volumes) :: Double
      percent = round $ avg * 100 / fromIntegral maxVolume
  return percent

defaultMaxVolume :: Word32
defaultMaxVolume = 65536

setDeviceMute :: Client -> ObjectPath -> Bool -> IO Bool
setDeviceMute client path muteState = do
  let muteCall =
        (methodCall path paDeviceInterfaceName "Mute")
          { methodCallDestination = Nothing
          }
  err <- setPropertyValue client muteCall muteState
  pure $ isNothing err

setDeviceVolume :: Client -> ObjectPath -> Word32 -> [Word32] -> Int -> IO Bool
setDeviceVolume client path maxVol volumes deltaPercent = do
  let maxI = fromIntegral maxVol :: Int
      delta = round (fromIntegral maxI * fromIntegral deltaPercent / 100.0 :: Double)
      newVolumes = map (fromIntegral . clamp 0 maxI . (+ delta) . fromIntegral) volumes :: [Word32]
      volCall =
        (methodCall path paDeviceInterfaceName "Volume")
          { methodCallDestination = Nothing
          }
  err <- setPropertyValue client volCall newVolumes
  pure $ isNothing err

clamp :: Int -> Int -> Int -> Int
clamp low high value = max low (min high value)

isObjectPath :: String -> Bool
isObjectPath ('/' : _) = True
isObjectPath _ = False

findM :: (Monad m) => (a -> m Bool) -> [a] -> m (Maybe a)
findM _ [] = return Nothing
findM predicate (x : xs) = do
  matches <- predicate x
  if matches
    then return (Just x)
    else findM predicate xs

readDictMaybe :: (IsVariant a) => Map Text Variant -> Text -> Maybe a
readDictMaybe dict key = M.lookup key dict >>= fromVariant

getProperties ::
  Client ->
  Maybe BusName ->
  ObjectPath ->
  InterfaceName ->
  IO (Either MethodError (Map Text Variant))
getProperties client destination path iface = runExceptT $ do
  reply <-
    ExceptT $
      getAllProperties client $
        (methodCall path iface "FakeMethod")
          { methodCallDestination = destination
          }
  ExceptT $
    return $
      maybeToEither dummyMethodError $
        listToMaybe (methodReturnBody reply) >>= fromVariant

-- XXX: Remove this once it is exposed in haskell-dbus
-- Same workaround pattern used elsewhere in Taffybar.
dummyMethodError :: MethodError
dummyMethodError = methodError (Serial 1) $ errorName_ "org.ClientTypeMismatch"