packages feed

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

{-# LANGUAGE OverloadedStrings #-}

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

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

-- |
-- Module      : System.Taffybar.Information.NetworkManager
-- Copyright   : (c) Ivan A. Malison
-- License     : BSD3-style (see LICENSE)
--
-- Maintainer  : Ivan A. Malison
-- Stability   : unstable
-- Portability : unportable
--
-- This module provides information about the active WiFi connection using
-- NetworkManager's DBus API.
module System.Taffybar.Information.NetworkManager
  ( WifiInfo (..),
    WifiState (..),
    NetworkInfo (..),
    NetworkState (..),
    NetworkType (..),
    getWifiInfo,
    getWifiInfoFromClient,
    getWifiInfoChan,
    getWifiInfoState,
    getNetworkInfo,
    getNetworkInfoFromClient,
    getNetworkInfoChan,
    getNetworkInfoState,
  )
where

import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Monad.IO.Class
import Control.Monad.STM (atomically)
import Control.Monad.Trans.Class
import Control.Monad.Trans.Except
import Control.Monad.Trans.Reader
import DBus
import DBus.Client
import DBus.Internal.Types (Serial (..))
import qualified DBus.TH as DBus
import qualified Data.ByteString as BS
import qualified Data.List as List
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Encoding.Error as TEE
import Data.Word (Word8)
import System.Log.Logger
import System.Taffybar.Context
import System.Taffybar.DBus.Client.Params
import System.Taffybar.Util (logPrintF, maybeToEither)

-- | High-level WiFi connectivity state from NetworkManager.
data WifiState
  = WifiDisabled
  | WifiDisconnected
  | WifiConnected
  | WifiUnknown
  deriving (Eq, Show)

-- | Snapshot of WiFi-specific connection information.
data WifiInfo = WifiInfo
  { wifiState :: WifiState,
    wifiSsid :: Maybe Text,
    wifiStrength :: Maybe Int,
    wifiConnectionId :: Maybe Text
  }
  deriving (Eq, Show)

-- | High-level non-device-specific network connectivity state.
data NetworkState
  = NetworkConnected
  | NetworkDisconnected
  | NetworkUnknown
  deriving (Eq, Show)

-- | Active network transport type.
data NetworkType
  = NetworkWifi
  | NetworkWired
  | NetworkVpn
  | NetworkOther Text
  deriving (Eq, Show)

-- | Snapshot of the current active network connection.
data NetworkInfo = NetworkInfo
  { networkState :: NetworkState,
    networkType :: Maybe NetworkType,
    networkSsid :: Maybe Text,
    networkStrength :: Maybe Int,
    networkConnectionId :: Maybe Text,
    networkWirelessEnabled :: Maybe Bool
  }
  deriving (Eq, Show)

wifiLogPath :: String
wifiLogPath = "System.Taffybar.Information.NetworkManager"

wifiLogF :: (MonadIO m, Show t) => Priority -> String -> t -> m ()
wifiLogF = logPrintF wifiLogPath

nullObjectPath :: ObjectPath
nullObjectPath = objectPath_ "/"

wifiUnknownInfo :: WifiInfo
wifiUnknownInfo =
  WifiInfo
    { wifiState = WifiUnknown,
      wifiSsid = Nothing,
      wifiStrength = Nothing,
      wifiConnectionId = Nothing
    }

wifiDisabledInfo :: WifiInfo
wifiDisabledInfo =
  WifiInfo
    { wifiState = WifiDisabled,
      wifiSsid = Nothing,
      wifiStrength = Nothing,
      wifiConnectionId = Nothing
    }

wifiDisconnectedInfo :: WifiInfo
wifiDisconnectedInfo =
  WifiInfo
    { wifiState = WifiDisconnected,
      wifiSsid = Nothing,
      wifiStrength = Nothing,
      wifiConnectionId = Nothing
    }

newtype WifiInfoChanVar = WifiInfoChanVar (TChan WifiInfo, MVar WifiInfo)

-- | Read the latest cached 'WifiInfo' value.
getWifiInfoState :: TaffyIO WifiInfo
getWifiInfoState = do
  WifiInfoChanVar (_, theVar) <- getWifiInfoChanVar
  lift $ readMVar theVar

-- | Get a broadcast channel of WiFi info updates.
getWifiInfoChan :: TaffyIO (TChan WifiInfo)
getWifiInfoChan = do
  WifiInfoChanVar (chan, _) <- getWifiInfoChanVar
  return chan

getWifiInfoChanVar :: TaffyIO WifiInfoChanVar
getWifiInfoChanVar =
  getStateDefault $ WifiInfoChanVar <$> monitorWifiInfo

monitorWifiInfo :: TaffyIO (TChan WifiInfo, MVar WifiInfo)
monitorWifiInfo = do
  _client <- asks systemDBusClient
  infoVar <- lift $ newMVar wifiUnknownInfo
  chan <- liftIO newBroadcastTChanIO
  taffyFork $ do
    ctx <- ask
    let updateInfo = updateWifiInfo chan infoVar
        signalCallback _ _ _ _ = runReaderT updateInfo ctx
    _ <- registerForNetworkManagerPropertiesChanged signalCallback
    _ <- registerForActiveConnectionPropertiesChanged signalCallback
    _ <- registerForAccessPointPropertiesChanged signalCallback
    updateInfo
  return (chan, infoVar)

registerForNetworkManagerPropertiesChanged ::
  (Signal -> String -> Map String Variant -> [String] -> IO ()) ->
  ReaderT Context IO SignalHandler
registerForNetworkManagerPropertiesChanged signalHandler = do
  client <- asks systemDBusClient
  lift $
    DBus.registerForPropertiesChanged
      client
      matchAny
        { matchInterface = Just nmInterfaceName,
          matchPath = Just nmObjectPath
        }
      signalHandler

registerForActiveConnectionPropertiesChanged ::
  (Signal -> String -> Map String Variant -> [String] -> IO ()) ->
  ReaderT Context IO SignalHandler
registerForActiveConnectionPropertiesChanged signalHandler = do
  client <- asks systemDBusClient
  lift $
    DBus.registerForPropertiesChanged
      client
      matchAny
        { matchInterface = Just nmActiveConnectionInterfaceName,
          matchPathNamespace = Just nmActiveConnectionPathNamespace
        }
      signalHandler

registerForAccessPointPropertiesChanged ::
  (Signal -> String -> Map String Variant -> [String] -> IO ()) ->
  ReaderT Context IO SignalHandler
registerForAccessPointPropertiesChanged signalHandler = do
  client <- asks systemDBusClient
  lift $
    DBus.registerForPropertiesChanged
      client
      matchAny
        { matchInterface = Just nmAccessPointInterfaceName,
          matchPathNamespace = Just nmAccessPointPathNamespace
        }
      signalHandler

updateWifiInfo ::
  TChan WifiInfo ->
  MVar WifiInfo ->
  TaffyIO ()
updateWifiInfo chan var = do
  info <- getWifiInfo
  lift $ do
    _ <- swapMVar var info
    atomically $ writeTChan chan info

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

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

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

-- | Query WiFi information using taffybar's shared DBus client.
getWifiInfo :: TaffyIO WifiInfo
getWifiInfo = asks systemDBusClient >>= liftIO . getWifiInfoFromClient

-- | Query WiFi information from the provided NetworkManager DBus client.
getWifiInfoFromClient :: Client -> IO WifiInfo
getWifiInfoFromClient client = do
  nmPropsResult <- getProperties client nmObjectPath nmInterfaceName
  case nmPropsResult of
    Left err -> do
      wifiLogF WARNING "Failed to read NetworkManager properties: %s" err
      return wifiUnknownInfo
    Right nmProps -> do
      let wirelessEnabled = readDictMaybe nmProps "WirelessEnabled" :: Maybe Bool
          activeConnections =
            readDictMaybe nmProps "ActiveConnections" :: Maybe [ObjectPath]
      case wirelessEnabled of
        Just False -> return wifiDisabledInfo
        Just True -> case activeConnections of
          Just paths ->
            fromMaybe wifiDisconnectedInfo
              <$> findActiveWifi client paths
          Nothing -> do
            logM wifiLogPath WARNING "NetworkManager missing ActiveConnections"
            return wifiUnknownInfo
        Nothing -> do
          logM wifiLogPath WARNING "NetworkManager missing WirelessEnabled"
          return wifiUnknownInfo

findActiveWifi :: Client -> [ObjectPath] -> IO (Maybe WifiInfo)
findActiveWifi _ [] = return Nothing
findActiveWifi client (path : rest) = do
  connPropsResult <- getProperties client path nmActiveConnectionInterfaceName
  case connPropsResult of
    Left err -> do
      wifiLogF DEBUG "Failed to read active connection %s" err
      findActiveWifi client rest
    Right connProps -> do
      let connType = readDictMaybe connProps "Type" :: Maybe Text
      if connType /= Just "802-11-wireless"
        then findActiveWifi client rest
        else do
          let connId = readDictMaybe connProps "Id" :: Maybe Text
              specificObject =
                readDictMaybe connProps "SpecificObject" :: Maybe ObjectPath
          (ssid, strength) <- getAccessPointInfo client specificObject
          return $
            Just
              WifiInfo
                { wifiState = WifiConnected,
                  wifiSsid = ssid,
                  wifiStrength = strength,
                  wifiConnectionId = connId
                }

getAccessPointInfo ::
  Client ->
  Maybe ObjectPath ->
  IO (Maybe Text, Maybe Int)
getAccessPointInfo _ Nothing = return (Nothing, Nothing)
getAccessPointInfo _ (Just path)
  | path == nullObjectPath =
      return (Nothing, Nothing)
getAccessPointInfo client (Just path) = do
  apPropsResult <- getProperties client path nmAccessPointInterfaceName
  case apPropsResult of
    Left err -> do
      wifiLogF DEBUG "Failed to read access point properties %s" err
      return (Nothing, Nothing)
    Right apProps -> do
      let ssidBytes = readDictMaybe apProps "Ssid" :: Maybe [Word8]
          strength = readDictMaybe apProps "Strength" :: Maybe Word8
      return
        ( ssidBytes >>= decodeSsid,
          fromIntegral <$> strength
        )

decodeSsid :: [Word8] -> Maybe Text
decodeSsid bytes
  | null bytes = Nothing
  | otherwise = Just $ TE.decodeUtf8With TEE.lenientDecode (BS.pack bytes)

-- Network info (WiFi + wired + VPN + disconnected)

newtype NetworkInfoChanVar = NetworkInfoChanVar (TChan NetworkInfo, MVar NetworkInfo)

networkUnknownInfo :: NetworkInfo
networkUnknownInfo =
  NetworkInfo
    { networkState = NetworkUnknown,
      networkType = Nothing,
      networkSsid = Nothing,
      networkStrength = Nothing,
      networkConnectionId = Nothing,
      networkWirelessEnabled = Nothing
    }

-- | Read the latest cached 'NetworkInfo' value.
getNetworkInfoState :: TaffyIO NetworkInfo
getNetworkInfoState = do
  NetworkInfoChanVar (_, theVar) <- getNetworkInfoChanVar
  lift $ readMVar theVar

-- | Get a broadcast channel of network info updates.
getNetworkInfoChan :: TaffyIO (TChan NetworkInfo)
getNetworkInfoChan = do
  NetworkInfoChanVar (chan, _) <- getNetworkInfoChanVar
  return chan

getNetworkInfoChanVar :: TaffyIO NetworkInfoChanVar
getNetworkInfoChanVar =
  getStateDefault $ NetworkInfoChanVar <$> monitorNetworkInfo

monitorNetworkInfo :: TaffyIO (TChan NetworkInfo, MVar NetworkInfo)
monitorNetworkInfo = do
  infoVar <- lift $ newMVar networkUnknownInfo
  chan <- liftIO newBroadcastTChanIO
  taffyFork $ do
    ctx <- ask
    let updateInfo = updateNetworkInfo chan infoVar
        signalCallback _ _ _ _ = runReaderT updateInfo ctx
    _ <- registerForNetworkManagerPropertiesChanged signalCallback
    _ <- registerForActiveConnectionPropertiesChanged signalCallback
    _ <- registerForAccessPointPropertiesChanged signalCallback
    updateInfo
  return (chan, infoVar)

updateNetworkInfo ::
  TChan NetworkInfo ->
  MVar NetworkInfo ->
  TaffyIO ()
updateNetworkInfo chan var = do
  info <- getNetworkInfo
  lift $ do
    _ <- swapMVar var info
    atomically $ writeTChan chan info

-- | Query network information using taffybar's shared DBus client.
getNetworkInfo :: TaffyIO NetworkInfo
getNetworkInfo = asks systemDBusClient >>= liftIO . getNetworkInfoFromClient

data ActiveConnectionInfo = ActiveConnectionInfo
  { activeConnectionPath :: ObjectPath,
    activeConnectionType :: Text,
    activeConnectionId :: Maybe Text,
    activeConnectionSpecificObject :: Maybe ObjectPath
  }
  deriving (Eq, Show)

-- | Query network information from the provided NetworkManager DBus client.
getNetworkInfoFromClient :: Client -> IO NetworkInfo
getNetworkInfoFromClient client = do
  nmPropsResult <- getProperties client nmObjectPath nmInterfaceName
  case nmPropsResult of
    Left err -> do
      wifiLogF WARNING "Failed to read NetworkManager properties: %s" err
      return networkUnknownInfo
    Right nmProps -> do
      let wirelessEnabled = readDictMaybe nmProps "WirelessEnabled" :: Maybe Bool
          activeConnections =
            readDictMaybe nmProps "ActiveConnections" :: Maybe [ObjectPath]

      activeInfos <- maybe (return []) (mapM (getActiveConnectionInfo client)) activeConnections
      let best = pickBestActiveConnection activeInfos

      case best of
        Nothing ->
          return
            networkUnknownInfo
              { networkState = NetworkDisconnected,
                networkWirelessEnabled = wirelessEnabled
              }
        Just ac -> do
          (ssid, strength) <-
            if activeConnectionType ac == "802-11-wireless"
              then getAccessPointInfo client (activeConnectionSpecificObject ac)
              else return (Nothing, Nothing)
          return
            networkUnknownInfo
              { networkState = NetworkConnected,
                networkType = Just $ toNetworkType (activeConnectionType ac),
                networkSsid = ssid,
                networkStrength = strength,
                networkConnectionId = activeConnectionId ac,
                networkWirelessEnabled = wirelessEnabled
              }

getActiveConnectionInfo :: Client -> ObjectPath -> IO ActiveConnectionInfo
getActiveConnectionInfo client path = do
  connPropsResult <- getProperties client path nmActiveConnectionInterfaceName
  case connPropsResult of
    Left err -> do
      wifiLogF DEBUG "Failed to read active connection %s" err
      return
        ActiveConnectionInfo
          { activeConnectionPath = path,
            activeConnectionType = "",
            activeConnectionId = Nothing,
            activeConnectionSpecificObject = Nothing
          }
    Right connProps -> do
      let connType = fromMaybe "" (readDictMaybe connProps "Type" :: Maybe Text)
          connId = readDictMaybe connProps "Id" :: Maybe Text
          specificObject =
            readDictMaybe connProps "SpecificObject" :: Maybe ObjectPath
      return
        ActiveConnectionInfo
          { activeConnectionPath = path,
            activeConnectionType = connType,
            activeConnectionId = connId,
            activeConnectionSpecificObject = specificObject
          }

pickBestActiveConnection :: [ActiveConnectionInfo] -> Maybe ActiveConnectionInfo
pickBestActiveConnection [] = Nothing
pickBestActiveConnection (firstInfo : restInfos) =
  let rank t
        | t == "802-3-ethernet" = 0 :: Int
        | t == "802-11-wireless" = 1
        | t == "vpn" = 2
        | T.null t = 99
        | otherwise = 50
      betterActiveConnection a b
        | rank (activeConnectionType a) <= rank (activeConnectionType b) = a
        | otherwise = b
   in Just $ List.foldl' betterActiveConnection firstInfo restInfos

toNetworkType :: Text -> NetworkType
toNetworkType t
  | t == "802-11-wireless" = NetworkWifi
  | t == "802-3-ethernet" = NetworkWired
  | t == "vpn" = NetworkVpn
  | otherwise = NetworkOther t