packages feed

wlsunset-sni-0.1.0.0: src/Wlsunset/SNI.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Wlsunset.SNI
  ( Config (..)
  , defaultConfig
  , runWlsunsetSNI
  )
where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM
import Control.Exception (SomeException, catch)
import Control.Monad (forever, void, when)
import Data.Int (Int32)
import Data.String (fromString)
import qualified Data.Text as T
import DBus
import DBus.Client
  ( Client
  , Interface (..)
  , autoMethod
  , connectSession
  , defaultInterface
  , export
  , readOnlyProperty
  , requestName
  )
import DBus.Proxy (proxyAll)
import qualified Data.ByteString as BS
import qualified Data.GI.Base as GI
import Foreign.C.Types (CInt (..))
import Foreign.Ptr (FunPtr, Ptr)
import Foreign.StablePtr
  ( StablePtr
  , castPtrToStablePtr
  , castStablePtrToPtr
  , deRefStablePtr
  , freeStablePtr
  , newStablePtr
  )
import qualified GI.Dbusmenu as Dbusmenu
import qualified GI.GLib as GLib
import qualified GI.Gio as Gio
import qualified GI.Gio.Objects.Cancellable as Gio
import System.Exit (exitSuccess)
import System.IO.Unsafe (unsafePerformIO)
import System.Log.Logger
  ( Priority (..)
  , getRootLogger
  , saveGlobalLogger
  , setLevel
  )

import qualified StatusNotifier.Item.Client as I
import qualified StatusNotifier.Watcher.Client as W

import Wlsunset.Menu
import Wlsunset.Process

-- | Runtime configuration.
data Config = Config
  { configDBusName :: String
  , configWlsunsetCommand :: String
  , configHighTemp :: Int
  , configLowTemp :: Int
  , configPollIntervalSec :: Int
  , configIconAuto :: String
  , configIconHigh :: String
  , configIconLow :: String
  , configIconOff :: String
  , configTitle :: String
  , configLogLevel :: Priority
  }

defaultConfig :: Config
defaultConfig =
  Config
    { configDBusName = "org.taffybar.WlsunsetSNI"
    , configWlsunsetCommand = "wlsunset"
    , configHighTemp = 6500
    , configLowTemp = 4000
    , configPollIntervalSec = 2
    , configIconAuto = "video-display"
    , configIconHigh = "video-display"
    , configIconLow = "weather-clear-night"
    , configIconOff = "process-stop"
    , configTitle = "wlsunset"
    , configLogLevel = WARNING
    }

runWlsunsetSNI :: Config -> IO ()
runWlsunsetSNI cfg@Config {..} = do
  setupLogging configLogLevel

  let busName = configDBusName
      path = "/StatusNotifierItem"
      menuPath = path <> "/Menu"
      menuBusName = busName <> ".Menu"
      iconObjectPath = objectPath_ path

  client <- connectSession

  -- Menu is hosted via gi-dbusmenu on a separate bus name, then proxied onto
  -- the item bus name (see DBus.Proxy).
  menuConnection <- Just <$> Gio.cancellableNew >>= Gio.busGetSync Gio.BusTypeSession
  void $ Gio.busOwnNameOnConnection menuConnection (T.pack menuBusName) [] Nothing Nothing
  menuServer <- Dbusmenu.serverNew (T.pack menuPath)

  -- GLib main loop needed by gi-dbusmenu.
  mainLoop <- GLib.mainLoopNew Nothing False >>= GLib.mainLoopRef
  glibContext <- GLib.mainLoopGetContext mainLoop
  void $ forkIO $ GLib.mainLoopRun mainLoop

  let wsCfg =
        WlsunsetConfig
          { wlsunsetCommand = configWlsunsetCommand
          , wlsunsetHighTemp = configHighTemp
          , wlsunsetLowTemp = configLowTemp
          , wlsunsetPollIntervalSec = configPollIntervalSec
          }
  wlsunset <- newWlsunset wsCfg

  let rebuildMenu :: IO ()
      rebuildMenu = do
        st <- getWlsunsetState wlsunset
        let actions =
              MenuActions
                { onSetMode = \mode -> do
                    st0 <- getWlsunsetState wlsunset
                    when (not (wlsunsetRunning st0)) (startWlsunset wlsunset)
                    cycleWlsunsetToMode wlsunset mode
                , onToggle = toggleWlsunset wlsunset
                , onSetFixedTemp = \t -> restartWlsunsetWithTemps wlsunset t t
                , onResetTemps = restartWlsunsetWithTemps wlsunset configLowTemp configHighTemp
                , onQuit = exitSuccess
                }
        newRoot <- buildMenu wsCfg st actions
        runOnGLibMain glibContext $
          Dbusmenu.serverSetRoot menuServer newRoot

  -- Initial menu
  rebuildMenu

  exportSNI cfg client path menuPath wlsunset
  _ <- requestName client (busName_ busName) []

  -- Proxy the menu from menuBusName onto busName at the same object path, so
  -- hosts can find com.canonical.dbusmenu at busName:/StatusNotifierItem/Menu.
  proxyAll client (busName_ menuBusName) (objectPath_ menuPath) (objectPath_ menuPath)

  -- Register with the watcher (must be done from the same DBus connection that
  -- owns the SNI bus name).
  void $ W.registerStatusNotifierItem client busName

  -- React to state changes.
  chan <- dupWlsunsetChan wlsunset
  void $ forkIO $ forever $ do
    _st <- atomically $ readTChan chan
    rebuildMenu
    emitSafe $ I.emitNewIcon client iconObjectPath
    emitSafe $ I.emitNewToolTip client iconObjectPath

  forever $ threadDelay maxBound

exportSNI :: Config -> Client -> String -> String -> Wlsunset -> IO ()
exportSNI cfg client path menuPath wlsunset = do
  let iface =
        defaultInterface
          { interfaceName = interfaceName_ "org.kde.StatusNotifierItem"
          , interfaceMethods =
              [ autoMethod "SecondaryActivate" (\(_x :: Int32) (_y :: Int32) -> toggleWlsunset wlsunset)
              , autoMethod "Activate" (\(_x :: Int32) (_y :: Int32) -> (return () :: IO ()))
              , autoMethod "ContextMenu" (\(_x :: Int32) (_y :: Int32) -> (return () :: IO ()))
              , autoMethod "Scroll" (\(_delta :: Int32) (_orientation :: String) -> (return () :: IO ()))
              ]
          , interfaceProperties =
              [ readOnlyProperty "Category" (pure ("ApplicationStatus" :: String))
              , readOnlyProperty "Id" (pure ("wlsunset-sni" :: String))
              , readOnlyProperty "Title" (pure (configTitle cfg))
              , readOnlyProperty "Status" (pure ("Active" :: String))
              , readOnlyProperty "WindowId" (pure (0 :: Int32))
              , readOnlyProperty "IconThemePath" (pure ("" :: String))
              , readOnlyProperty "IconName" (stateIconName cfg <$> getWlsunsetState wlsunset)
              , readOnlyProperty "OverlayIconName" (pure ("" :: String))
              , readOnlyProperty "ItemIsMenu" (pure True)
              , readOnlyProperty "Menu" (pure (objectPath_ menuPath))
              , readOnlyProperty "ToolTip" (getWlsunsetState wlsunset >>= tooltip cfg)
              ]
          , interfaceSignals = []
          }
  export client (fromString path) iface

stateIconName :: Config -> WlsunsetState -> String
stateIconName Config {..} st
  | not (wlsunsetRunning st) = configIconOff
  | otherwise =
      case wlsunsetMode st of
        WlsunsetAuto -> configIconAuto
        WlsunsetForcedHighTemp -> configIconHigh
        WlsunsetForcedLowTemp -> configIconLow

-- org.kde.StatusNotifierItem.ToolTip is (s a(iiay) s s)
-- We don't provide pixmaps; most hosts are fine with IconName + text.
tooltip :: Config -> WlsunsetState -> IO (String, [(Int32, Int32, BS.ByteString)], String, String)
tooltip cfg st =
  let title = configTitle cfg
      text = T.unpack (tooltipText st)
   in pure ("", [], title, text)

tooltipText :: WlsunsetState -> T.Text
tooltipText st =
  let highT = T.pack (show (wlsunsetEffectiveHighTemp st) <> "K")
      lowT = T.pack (show (wlsunsetEffectiveLowTemp st) <> "K")
   in case (wlsunsetRunning st, wlsunsetMode st) of
        (False, _) -> "wlsunset: stopped"
        (True, WlsunsetAuto) -> "wlsunset: automatic (" <> lowT <> " - " <> highT <> ")"
        (True, WlsunsetForcedHighTemp) -> "wlsunset: high temp (" <> highT <> ")"
        (True, WlsunsetForcedLowTemp) -> "wlsunset: low temp (" <> lowT <> ")"

setupLogging :: Priority -> IO ()
setupLogging level = do
  logger <- getRootLogger
  saveGlobalLogger (setLevel level logger)

emitSafe :: IO () -> IO ()
emitSafe io = io `catch` (\(_ :: SomeException) -> pure ())

-- Schedule work on the GLib main context.
runOnGLibMain :: GLib.MainContext -> IO () -> IO ()
runOnGLibMain context action = do
  sp <- newStablePtr action
  GI.withManagedPtr context $ \ctxPtr ->
    c_g_main_context_invoke_full
      ctxPtr
      4
      invokeSourceFunc
      (castStablePtrToPtr sp)
      destroyNotifyFunc

type InvokeSourceFunc = Ptr () -> IO CInt

type DestroyNotify = Ptr () -> IO ()

foreign import ccall unsafe "g_main_context_invoke_full"
  c_g_main_context_invoke_full ::
    Ptr GLib.MainContext ->
    CInt ->
    FunPtr InvokeSourceFunc ->
    Ptr () ->
    FunPtr DestroyNotify ->
    IO ()

foreign import ccall "wrapper"
  mkInvokeSourceFunc :: InvokeSourceFunc -> IO (FunPtr InvokeSourceFunc)

foreign import ccall "wrapper"
  mkDestroyNotify :: DestroyNotify -> IO (FunPtr DestroyNotify)

{-# NOINLINE invokeSourceFunc #-}
invokeSourceFunc :: FunPtr InvokeSourceFunc
invokeSourceFunc =
  unsafePerformIO $
    mkInvokeSourceFunc $ \p -> do
      let sp :: StablePtr (IO ())
          sp = castPtrToStablePtr p
      (deRefStablePtr sp >>= id) `catch` (\(_ :: SomeException) -> pure ())
      pure 0

{-# NOINLINE destroyNotifyFunc #-}
destroyNotifyFunc :: FunPtr DestroyNotify
destroyNotifyFunc =
  unsafePerformIO $
    mkDestroyNotify $ \p -> do
      let sp :: StablePtr (IO ())
          sp = castPtrToStablePtr p
      freeStablePtr sp