{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -- | This widget listens on DBus for freedesktop notifications -- (http://developer.gnome.org/notification-spec/). Currently it is -- somewhat ugly, but the format is somewhat configurable. A visual -- overhaul of the widget is coming. -- -- The widget only displays one notification at a time and -- notifications are cancellable. module System.Taffybar.FreedesktopNotifications ( -- * Types Notification(..), NotificationConfig(..), -- * Constructor notifyAreaNew, defaultNotificationConfig ) where import Control.Concurrent import Control.Concurrent.STM import Control.Monad ( forever ) import Control.Monad.Trans ( liftIO ) import Data.Int ( Int32 ) import Data.Map ( Map ) import Data.Monoid ( mconcat ) import qualified Data.Sequence as S import Data.Sequence ( Seq, (|>), viewl, ViewL(..) ) import Data.Text ( Text ) import qualified Data.Text as T import Data.Word ( Word32 ) import DBus import DBus.Client import Graphics.UI.Gtk hiding ( Variant ) -- | A simple structure representing a Freedesktop notification data Notification = Notification { noteAppName :: Text , noteReplaceId :: Word32 , noteSummary :: Text , noteBody :: Text , noteExpireTimeout :: Int32 , noteId :: Word32 } deriving (Show, Eq) data NotifyState = NotifyState { noteWidget :: Label , noteContainer :: Widget , noteConfig :: NotificationConfig , noteQueue :: TVar (Seq Notification) -- ^ The queue of active (but not yet -- displayed) notifications , noteIdSource :: TVar Word32 -- ^ A source of new notification ids , noteCurrent :: TVar (Maybe Notification) -- ^ The current note being displayed , noteChan :: Chan () -- ^ Wakes up the GUI update thread } initialNoteState :: Widget -> Label -> NotificationConfig -> IO NotifyState initialNoteState wrapper l cfg = do m <- newTVarIO 1 q <- newTVarIO S.empty c <- newTVarIO Nothing ch <- newChan return NotifyState { noteQueue = q , noteIdSource = m , noteWidget = l , noteContainer = wrapper , noteCurrent = c , noteConfig = cfg , noteChan = ch } getServerInformation :: IO (Text, Text, Text, Text) getServerInformation = return ("haskell-notification-daemon", "nochair.net", "0.0.1", "1.1") getCapabilities :: IO [Text] getCapabilities = return ["body", "body-markup"] nextNotification :: NotifyState -> STM () nextNotification s = do q <- readTVar (noteQueue s) case viewl q of EmptyL -> do writeTVar (noteCurrent s) Nothing next :< rest -> do writeTVar (noteQueue s) rest writeTVar (noteCurrent s) (Just next) -- | Filter any notifications with this id from the current queue. If -- it is the current notification, replace it with the next, if any. closeNotification :: NotifyState -> Word32 -> IO () closeNotification istate nid = do atomically $ do modifyTVar' (noteQueue istate) removeNote curNote <- readTVar (noteCurrent istate) case curNote of Nothing -> return () Just cnote | noteId cnote /= nid -> return () | otherwise -> -- in this case, the note was current so we take the next, -- if any nextNotification istate wakeupDisplayThread istate where removeNote = S.filter (\n -> noteId n /= nid) -- | Apply the user's formatter and truncate the result with the -- specified maxlen. formatMessage :: NotifyState -> Notification -> String formatMessage s = take maxlen . fmt where maxlen = notificationMaxLength $ noteConfig s fmt = notificationFormatter $ noteConfig s -- | The notificationDaemon thread looks at the notification queue. -- If the queue is empty and there is no current message, it sets the -- new message as the current message in a TVar (Just Notification) -- and displays the message itself and sets up a thread to remove the -- message after its timeout. -- -- If there is a current message, add the new message to the queue. -- -- The timeout thread just sleeps for its timeout and then atomically -- replaces the current message with the next one from the queue. It -- then displays the new current message. However, if the current -- message has changed (because of a user cancellation), the timer -- thread just exits. -- -- User cancellation atomically reads (and replaces) the current -- notification (if there is another in the queue). If it found a new -- notification, that node is then displayed. notify :: NotifyState -> Text -- ^ Application name -> Word32 -- ^ Replaces id -> Text -- ^ App icon -> Text -- ^ Summary -> Text -- ^ Body -> [Text] -- ^ Actions -> Map Text Variant -- ^ Hints -> Int32 -- ^ Expires timeout (milliseconds) -> IO Word32 notify istate appName replaceId _ summary body _ _ timeout = do nid <- atomically $ do tid <- readTVar idsrc modifyTVar' idsrc (+1) return tid let realId = if replaceId == 0 then fromIntegral nid else replaceId n = Notification { noteAppName = appName , noteReplaceId = replaceId , noteSummary = escapeText summary , noteBody = escapeText body , noteExpireTimeout = tout , noteId = realId } -- If we are replacing an existing note, atomically do the swap in -- the note queue and then make this the new current if the queue is -- empty OR if the current has this id. dn <- atomically $ do modifyTVar' (noteQueue istate) (replaceNote n) cnote <- readTVar (noteCurrent istate) case cnote of Nothing -> do writeTVar (noteCurrent istate) (Just n) return (Just n) Just curNote | noteId curNote == realId -> do writeTVar (noteCurrent istate) (Just n) return (Just n) | otherwise -> do modifyTVar' (noteQueue istate) (|>n) return Nothing -- This is a little gross - if we added the new notification to the -- queue, we can't call displayNote on it because that will -- obliterate the current active notification. case dn of -- take no action; timeout threads will handle it Nothing -> return () Just _ -> wakeupDisplayThread istate return realId where replaceNote newNote = fmap (\n -> if noteId n == noteReplaceId newNote then newNote else n) idsrc = noteIdSource istate escapeText = T.pack . escapeMarkup . T.unpack maxtout = fromIntegral $ notificationMaxTimeout (noteConfig istate) tout = case timeout of 0 -> maxtout (-1) -> maxtout _ -> min maxtout timeout notificationDaemon :: (AutoMethod f1, AutoMethod f2) => f1 -> f2 -> IO () notificationDaemon onNote onCloseNote = do client <- connectSession _ <- requestName client "org.freedesktop.Notifications" [nameAllowReplacement, nameReplaceExisting] export client "/org/freedesktop/Notifications" [ autoMethod "org.freedesktop.Notifications" "GetServerInformation" getServerInformation , autoMethod "org.freedesktop.Notifications" "GetCapabilities" getCapabilities , autoMethod "org.freedesktop.Notifications" "CloseNotification" onCloseNote , autoMethod "org.freedesktop.Notifications" "Notify" onNote ] -- | Wakeup the display thread and have it switch out the displayed -- message for the new current message. wakeupDisplayThread :: NotifyState -> IO () wakeupDisplayThread s = writeChan (noteChan s) () -- | This thread displayThread :: NotifyState -> IO () displayThread s = forever $ do _ <- readChan (noteChan s) cur <- atomically $ readTVar (noteCurrent s) case cur of Nothing -> postGUIAsync (widgetHideAll (noteContainer s)) Just n -> postGUIAsync $ do labelSetMarkup (noteWidget s) (formatMessage s n) widgetShowAll (noteContainer s) startTimeoutThread s n startTimeoutThread :: NotifyState -> Notification -> IO () startTimeoutThread s n = do _ <- forkIO $ do let seconds = noteExpireTimeout n threadDelay (fromIntegral seconds * 1000000) atomically $ do curNote <- readTVar (noteCurrent s) case curNote of Nothing -> return () Just cnote | cnote /= n -> return () | otherwise -> -- The note was not invalidated or changed since the timeout -- began, so we replace it with the next (if any) nextNotification s wakeupDisplayThread s return () data NotificationConfig = NotificationConfig { notificationMaxTimeout :: Int -- ^ Maximum time that a notification will be displayed (in seconds). Default: 10 , notificationMaxLength :: Int -- ^ Maximum length displayed, in characters. Default: 50 , notificationFormatter :: Notification -> String -- ^ Function used to format notifications } defaultFormatter :: Notification -> String defaultFormatter note = msg where msg = case T.null (noteBody note) of True -> T.unpack $ noteSummary note False -> T.unpack $ mconcat [ "Note:" , noteSummary note, ": ", noteBody note ] -- | The default formatter is one of -- -- * Summary : Body -- -- * Summary -- -- depending on the presence of a notification body. defaultNotificationConfig :: NotificationConfig defaultNotificationConfig = NotificationConfig { notificationMaxTimeout = 10 , notificationMaxLength = 100 , notificationFormatter = defaultFormatter } -- | Create a new notification area with the given configuration. notifyAreaNew :: NotificationConfig -> IO Widget notifyAreaNew cfg = do frame <- frameNew box <- hBoxNew False 3 textArea <- labelNew (Nothing :: Maybe String) button <- eventBoxNew sep <- vSeparatorNew bLabel <- labelNew (Nothing :: Maybe String) widgetSetName bLabel ("NotificationCloseButton" :: String) labelSetMarkup bLabel ("×" :: String) labelSetMaxWidthChars textArea (notificationMaxLength cfg) labelSetEllipsize textArea EllipsizeEnd containerAdd button bLabel boxPackStart box textArea PackGrow 0 boxPackStart box sep PackNatural 0 boxPackStart box button PackNatural 0 containerAdd frame box widgetHideAll frame istate <- initialNoteState (toWidget frame) textArea cfg _ <- on button buttonReleaseEvent (userCancel istate) realizableWrapper <- hBoxNew False 0 boxPackStart realizableWrapper frame PackNatural 0 widgetShow realizableWrapper -- We can't start the dbus listener thread until we are in the GTK -- main loop, otherwise things are prone to lock up and block -- infinitely on an mvar. Bad stuff - only start the dbus thread -- after the fake invisible wrapper widget is realized. _ <- on realizableWrapper realize $ do _ <- forkIO (displayThread istate) notificationDaemon (notify istate) (closeNotification istate) -- Don't show the widget by default - it will appear when needed return (toWidget realizableWrapper) where -- | Close the current note and pull up the next, if any userCancel s = do liftIO $ do atomically $ nextNotification s wakeupDisplayThread s return True