packages feed

status-notifier-item-0.3.2.15: src/StatusNotifier/Watcher/Service.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

module StatusNotifier.Watcher.Service where

import Control.Arrow
import Control.Concurrent.MVar
import Control.Monad
import Control.Monad.Trans.Class
import Control.Monad.Trans.Except
import DBus
import DBus.Client
import DBus.Internal.Message as M
import DBus.Internal.Types
import qualified DBus.Internal.Types as T
import qualified DBus.Introspection as I
import qualified DBus.TH as DBusTH
import Data.Coerce
import Data.Int
import Data.List
import Data.Maybe
import Data.String
import qualified StatusNotifier.Item.Client as Item
import StatusNotifier.Util
import StatusNotifier.Watcher.Constants
import StatusNotifier.Watcher.Signals
import StatusNotifier.Watcher.StateCache
import System.IO.Unsafe
import System.Log.Logger
import Text.Printf

-- | How long to wait for a cached item to answer introspection during
-- startup validation before dropping it.
persistedItemValidationTimeout :: Int
persistedItemValidationTimeout = 2 * 1000000

buildWatcher :: WatcherParams -> IO (Interface, IO RequestNameReply)
buildWatcher
  WatcherParams
    { watcherNamespace = interfaceNamespace,
      watcherStop = stopWatcher,
      watcherPath = path,
      watcherDBusClient = mclient,
      watcherStateCachePath = maybeCachePath
    } = do
    let watcherInterfaceName = getWatcherInterfaceName interfaceNamespace
        logNamespace = "StatusNotifier.Watcher.Service"
        logInfo = logM logNamespace INFO
        logDebug = logM logNamespace DEBUG
        logError = logM logNamespace ERROR
        -- Default level is now INFO (watcher/Main.hs). Keep generic per-request
        -- logging at DEBUG to avoid spamming INFO for frequent property reads.
        mkLogCb cb msg = lift (logDebug (show msg)) >> cb msg
        mkLogMethod method = method {methodHandler = mkLogCb $ methodHandler method}
        mkLogProperty name fn =
          readOnlyProperty name $ logDebug (coerce name ++ " Called") >> fn

    client <- maybe connectSession return mclient
    cachePath <-
      maybe
        (defaultWatcherStateCachePath interfaceNamespace path)
        pure
        maybeCachePath

    notifierItems <- newMVar []
    notifierHosts <- newMVar []

    let itemIsRegistered item items =
          isJust $ find (== item) items

        persistWatcherState = do
          currentItems <- readMVar notifierItems
          currentHosts <- readMVar notifierHosts
          let toPersisted entry =
                PersistedItemEntry
                  { persistedServiceName = coerce (serviceName entry),
                    persistedServicePath = coerce (servicePath entry)
                  }
              persistedState =
                PersistedWatcherState
                  { persistedItems = map toPersisted currentItems,
                    persistedHosts = map toPersisted currentHosts
                  }
          writePersistedWatcherState cachePath persistedState
            >>= either
              (logError . printf "Failed to persist watcher state to %s: %s" cachePath)
              (const $ return ())

        renderServiceName :: ItemEntry -> String
        renderServiceName
          ItemEntry
            { serviceName = busName,
              servicePath = itemPath
            } =
            let bus = (coerce busName :: String)
                objPath = (coerce itemPath :: String)
                defaultPath = (coerce Item.defaultPath :: String)
             in if objPath == defaultPath
                  then bus
                  else bus ++ objPath

        resolveOwner bus
          | ":" `isPrefixOf` (coerce bus :: String) = return (Just bus)
          | otherwise = do
              result <- DBusTH.getNameOwner client (coerce bus)
              return $ busName_ <$> either (const Nothing) Just result

        insertItemNoSignal owner item currentItems = do
          ownerPathMatches <-
            case owner of
              Nothing -> return []
              Just itemOwner ->
                filterM
                  ( \existingItem ->
                      if servicePath existingItem == servicePath item
                        then do
                          existingOwner <- resolveOwner (serviceName existingItem)
                          return $ existingOwner == Just itemOwner
                        else return False
                  )
                  currentItems
          if itemIsRegistered item currentItems
            then return (currentItems, False)
            else
              if null ownerPathMatches
                then return (item : currentItems, True)
                else
                  let removeMatches =
                        foldl' (flip delete) currentItems ownerPathMatches
                   in return (item : removeMatches, True)

        insertHostNoSignal host currentHosts =
          if itemIsRegistered host currentHosts
            then (currentHosts, False)
            else (host : currentHosts, True)

        parsePersistedItemEntry persistedEntry = do
          parsedBusName <- T.parseBusName (persistedServiceName persistedEntry)
          parsedPath <- T.parseObjectPath (persistedServicePath persistedEntry)
          return
            ItemEntry
              { serviceName = parsedBusName,
                servicePath = parsedPath
              }

        statusNotifierItemInterfaceName = fromString "org.kde.StatusNotifierItem"
        hasStatusNotifierItemInterface objectInfo =
          any ((== statusNotifierItemInterfaceName) . I.interfaceName) $
            I.objectInterfaces objectInfo

        validatePersistedItem persistedEntry =
          case parsePersistedItemEntry persistedEntry of
            Nothing -> do
              logError $
                printf "Dropping unparsable cached item entry: %s" $
                  show persistedEntry
              return Nothing
            Just item -> do
              owner <- resolveOwner (serviceName item)
              case owner of
                Nothing -> do
                  logInfo $
                    printf
                      "Dropping cached item %s because the bus name is no longer owned."
                      (renderServiceName item)
                  return Nothing
                Just validOwner -> do
                  -- Bound the introspection call: a wedged item process that
                  -- owns its bus name but never services its connection would
                  -- otherwise block watcher startup forever, before the
                  -- watcher object is even exported.
                  objectResult <-
                    callWithTimeout persistedItemValidationTimeout $
                      getInterfaceAt client (serviceName item) (servicePath item)
                  case objectResult of
                    Right (Just objectInfo)
                      | hasStatusNotifierItemInterface objectInfo ->
                          return $ Just (item, validOwner)
                    _ -> do
                      logInfo $
                        printf
                          "Dropping cached item %s because it no longer exposes org.kde.StatusNotifierItem."
                          (renderServiceName item)
                      return Nothing

        validatePersistedHost persistedEntry =
          case parsePersistedItemEntry persistedEntry of
            Nothing -> do
              logError $
                printf "Dropping unparsable cached host entry: %s" $
                  show persistedEntry
              return Nothing
            Just host -> do
              owner <- resolveOwner (serviceName host)
              if isNothing owner
                then do
                  logInfo $
                    printf
                      "Dropping cached host %s because the bus name is no longer owned."
                      (coerce (serviceName host) :: String)
                  return Nothing
                else return $ Just host

        restoreWatcherStateFromCache = do
          stateResult <- readPersistedWatcherState cachePath
          case stateResult of
            Left err -> do
              logError $
                printf "Failed to read watcher cache state from %s: %s" cachePath err
              return ([], [])
            Right Nothing -> return ([], [])
            Right (Just persistedState) -> do
              validItems <- catMaybes <$> mapM validatePersistedItem (persistedItems persistedState)
              validHosts <- catMaybes <$> mapM validatePersistedHost (persistedHosts persistedState)

              restoredItems <-
                modifyMVar notifierItems $ \currentItems -> do
                  (newItems, insertedItemsRev) <-
                    foldM
                      ( \(accItems, accInserted) (item, itemOwner) -> do
                          (nextItems, inserted) <- insertItemNoSignal (Just itemOwner) item accItems
                          if inserted
                            then return (nextItems, item : accInserted)
                            else return (nextItems, accInserted)
                      )
                      (currentItems, [])
                      validItems
                  return (newItems, reverse insertedItemsRev)

              restoredHosts <-
                modifyMVar notifierHosts $ \currentHosts -> do
                  let insertHost (accHosts, accInserted) host =
                        let (nextHosts, inserted) = insertHostNoSignal host accHosts
                         in if inserted
                              then (nextHosts, host : accInserted)
                              else (nextHosts, accInserted)
                      (newHosts, insertedHostsRev) = foldl' insertHost (currentHosts, []) validHosts
                  return (newHosts, reverse insertedHostsRev)

              persistWatcherState
              return (restoredItems, restoredHosts)

        registerStatusNotifierItem
          MethodCall
            { methodCallSender = sender
            }
          name = runExceptT $ do
            let parsedBusName = T.parseBusName name
                parseServiceError =
                  makeErrorReply errorInvalidParameters $
                    printf
                      "the provided service %s could not be parsed \
                      \as a bus name or an object path."
                      name
                senderMissingError =
                  makeErrorReply
                    errorInvalidParameters
                    "Unable to identify sender for registration."
                itemPath = fromMaybe Item.defaultPath $ T.parseObjectPath name
                remapErrorName =
                  left $
                    (`makeErrorReply` "Failed to verify ownership.")
                      . M.methodErrorName
            when (isNothing parsedBusName && isNothing (T.parseObjectPath name)) $
              throwE parseServiceError
            senderName <- ExceptT $ return $ maybeToEither senderMissingError sender
            busName <-
              case parsedBusName of
                Just providedBusName
                  -- Unique bus names (starting with ':') are assigned by the D-Bus
                  -- daemon and cannot be spoofed. Some applications (e.g. KDE's
                  -- KStatusNotifierItem) register their SNI on a separate D-Bus
                  -- connection, so the sender's unique name differs from the
                  -- registered item's unique name even though they belong to the
                  -- same process. Skip the ownership check for unique names.
                  | ":" `isPrefixOf` (coerce providedBusName :: String) ->
                      return providedBusName
                  | otherwise -> do
                      owner <-
                        ExceptT $
                          remapErrorName
                            <$> DBusTH.getNameOwner client (coerce providedBusName)
                      unless (owner == coerce senderName) $
                        throwE $
                          makeErrorReply errorInvalidParameters $
                            printf
                              "Sender %s does not own service %s."
                              (coerce senderName :: String)
                              name
                      return providedBusName
                Nothing -> return senderName
            let item =
                  ItemEntry
                    { serviceName = busName,
                      servicePath = itemPath
                    }
            changed <- lift $ modifyMVar notifierItems $ \currentItems -> do
              (newItems, inserted) <- insertItemNoSignal (Just senderName) item currentItems
              return (newItems, inserted)
            lift $
              when changed $ do
                logInfo $ printf "Registered item %s." (renderServiceName item)
                emitStatusNotifierItemRegistered client $ renderServiceName item
                persistWatcherState

        registerStatusNotifierHost name =
          let item =
                ItemEntry
                  { serviceName = busName_ name,
                    servicePath = "/StatusNotifierHost"
                  }
           in do
                changed <- modifyMVar notifierHosts $ \currentHosts ->
                  let (newHosts, inserted) = insertHostNoSignal item currentHosts
                   in return (newHosts, inserted)
                when changed $ do
                  logInfo $ printf "Registered host %s." name
                  emitStatusNotifierHostRegistered client
                  persistWatcherState

        registeredStatusNotifierItems :: IO [String]
        registeredStatusNotifierItems =
          map renderServiceName <$> readMVar notifierItems

        registeredSNIEntries :: IO [(String, String)]
        registeredSNIEntries =
          map getTuple <$> readMVar notifierItems
          where
            getTuple (ItemEntry bname itemPath) = (coerce bname, coerce itemPath)

        objectPathForItem :: String -> IO (Either Reply String)
        objectPathForItem name =
          case splitServiceName name of
            (_, Just itemPath) -> return $ Right itemPath
            (bus, Nothing) ->
              maybeToEither notFoundError
                . fmap (coerce . servicePath)
                . find ((== busName_ bus) . serviceName)
                <$> readMVar notifierItems
          where
            notFoundError =
              makeErrorReply errorInvalidParameters $
                printf "Service %s is not registered." name

        isStatusNotifierHostRegistered = not . null <$> readMVar notifierHosts

        protocolVersion = return 0 :: IO Int32

        filterDeadService :: String -> MVar [ItemEntry] -> IO [ItemEntry]
        filterDeadService deadService mvar =
          modifyMVar mvar $
            return . partition ((/= busName_ deadService) . serviceName)

        handleNameOwnerChanged _ name _oldOwner newOwner =
          when (newOwner == "") $ do
            removedItems <- filterDeadService name notifierItems
            unless (null removedItems) $ do
              logInfo $ printf "Unregistering item %s because it disappeared." name
              forM_ removedItems $ \item ->
                emitStatusNotifierItemUnregistered client $ renderServiceName item
            removedHosts <- filterDeadService name notifierHosts
            unless (null removedHosts) $
              logInfo $
                printf "Unregistering host %s because it disappeared." name
            when
              (not (null removedItems) || not (null removedHosts))
              persistWatcherState

        watcherMethods =
          map
            mkLogMethod
            [ autoMethodWithMsg
                "RegisterStatusNotifierItem"
                registerStatusNotifierItem,
              autoMethod
                "RegisterStatusNotifierHost"
                registerStatusNotifierHost,
              autoMethod
                "StopWatcher"
                stopWatcher,
              autoMethod
                "GetObjectPathForItemName"
                objectPathForItem
            ]

        watcherProperties =
          [ mkLogProperty
              "RegisteredStatusNotifierItems"
              registeredStatusNotifierItems,
            mkLogProperty
              "RegisteredSNIEntries"
              registeredSNIEntries,
            mkLogProperty
              "IsStatusNotifierHostRegistered"
              isStatusNotifierHostRegistered,
            mkLogProperty
              "ProtocolVersion"
              protocolVersion
          ]

        builtWatcherInterface =
          Interface
            { interfaceName = watcherInterfaceName,
              interfaceMethods = watcherMethods,
              interfaceProperties = watcherProperties,
              interfaceSignals = watcherSignals
            }

        startWatcher = do
          nameRequestResult <- requestName client (coerce watcherInterfaceName) []
          case nameRequestResult of
            NamePrimaryOwner ->
              do
                _ <-
                  DBusTH.registerForNameOwnerChanged
                    client
                    matchAny
                    handleNameOwnerChanged
                (restoredItems, restoredHosts) <- restoreWatcherStateFromCache
                export client (fromString path) builtWatcherInterface
                logInfo $
                  printf
                    "Restored %d cached items and %d cached hosts."
                    (length restoredItems)
                    (length restoredHosts)
                mapM_
                  (emitStatusNotifierItemRegistered client . renderServiceName)
                  restoredItems
                mapM_ (const $ emitStatusNotifierHostRegistered client) restoredHosts
            _ -> stopWatcher
          return nameRequestResult

    return (builtWatcherInterface, startWatcher)

-- For Client generation
-- TODO: get rid of unsafePerformIO here by making function that takes mvars so
-- IO isn't needed to build watcher
{-# NOINLINE watcherInterface #-}
watcherInterface :: I.Interface
watcherInterface = buildIntrospectionInterface clientInterface
  where
    (clientInterface, _) =
      unsafePerformIO $
        buildWatcher
          defaultWatcherParams {watcherDBusClient = Just undefined}