taffybar-4.1.2: src/System/Taffybar/Information/Privacy.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
-----------------------------------------------------------------------------
-- |
-- Module : System.Taffybar.Information.Privacy
-- Copyright : (c) Ivan A. Malison
-- License : BSD3-style (see LICENSE)
--
-- Maintainer : Ivan A. Malison
-- Stability : unstable
-- Portability : unportable
--
-- PipeWire-based privacy monitoring for microphone, camera, and screen sharing.
--
-- This module uses @pw-dump@ to detect active audio/video streams.
--
-----------------------------------------------------------------------------
module System.Taffybar.Information.Privacy
( -- * Data types
PrivacyInfo(..)
, PrivacyNode(..)
, NodeType(..)
-- * Query functions
, getPrivacyInfo
-- * Channel-based monitoring
, getPrivacyInfoChan
, getPrivacyInfoState
-- * Configuration
, PrivacyConfig(..)
, defaultPrivacyConfig
) where
import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.MVar
import Control.Concurrent.STM.TChan
import Control.Exception (SomeException, catch)
import Control.Monad (forever)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.STM (atomically)
import Data.Aeson
( FromJSON(..)
, (.:)
, (.:?)
, withObject
)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as BL
import Data.Default (Default(..))
import qualified Data.Text.Encoding as TE
import Data.List (nubBy)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import System.Log.Logger (Priority(..))
import System.Taffybar.Context (TaffyIO, getStateDefault)
import System.Taffybar.Util (logPrintF, runCommand)
-- | Type of privacy-relevant node.
data NodeType
= AudioInput -- ^ Microphone / audio capture
| AudioOutput -- ^ Audio playback (less privacy-sensitive, but useful)
| VideoInput -- ^ Camera / screen capture
deriving (Eq, Show, Generic, Ord)
-- | Information about an active privacy-relevant node.
data PrivacyNode = PrivacyNode
{ nodeType :: NodeType
, appName :: Text
, appIcon :: Maybe Text
, nodeName :: Text
, isMonitor :: Bool
} deriving (Eq, Show, Generic)
-- | Aggregated privacy information.
newtype PrivacyInfo = PrivacyInfo
{ activeNodes :: [PrivacyNode]
} deriving (Eq, Show, Generic)
-- | Configuration for the privacy monitor.
data PrivacyConfig = PrivacyConfig
{ privacyPollingInterval :: Double -- ^ Polling interval in seconds
, privacyPwDumpPath :: FilePath -- ^ Path to pw-dump command
, privacyIgnoreMonitors :: Bool -- ^ Whether to ignore monitor streams
, privacyIgnoreAudioOutput :: Bool -- ^ Whether to ignore audio output streams
} deriving (Eq, Show, Generic)
-- | Default privacy configuration.
defaultPrivacyConfig :: PrivacyConfig
defaultPrivacyConfig = PrivacyConfig
{ privacyPollingInterval = 2.0
, privacyPwDumpPath = "pw-dump"
, privacyIgnoreMonitors = True
, privacyIgnoreAudioOutput = True
}
instance Default PrivacyConfig where
def = defaultPrivacyConfig
privacyLogPath :: String
privacyLogPath = "System.Taffybar.Information.Privacy"
privacyLogF :: Show t => Priority -> String -> t -> IO ()
privacyLogF = logPrintF privacyLogPath
-- | Internal representation of a PipeWire object from pw-dump.
data PwObject = PwObject
{ pwId :: Int
, pwType :: Text
, pwInfo :: Maybe PwInfo
} deriving (Eq, Show, Generic)
data PwInfo = PwInfo
{ pwState :: Maybe Text
, pwProps :: Maybe PwProps
} deriving (Eq, Show, Generic)
data PwProps = PwProps
{ propMediaClass :: Maybe Text
, propMediaName :: Maybe Text
, propNodeName :: Maybe Text
, propAppName :: Maybe Text
, propAppIconName :: Maybe Text
, propPortalAppId :: Maybe Text
, propStreamMonitor :: Maybe Text
} deriving (Eq, Show, Generic)
instance FromJSON PwObject where
parseJSON = withObject "PwObject" $ \v -> PwObject
<$> v .: "id"
<*> v .: "type"
<*> v .:? "info"
instance FromJSON PwInfo where
parseJSON = withObject "PwInfo" $ \v -> PwInfo
<$> v .:? "state"
<*> v .:? "props"
instance FromJSON PwProps where
parseJSON = withObject "PwProps" $ \v -> PwProps
<$> v .:? "media.class"
<*> v .:? "media.name"
<*> v .:? "node.name"
<*> v .:? "application.name"
<*> v .:? "application.icon-name"
<*> v .:? "pipewire.access.portal.app_id"
<*> v .:? "stream.monitor"
-- | Get current privacy information by running pw-dump.
getPrivacyInfo :: PrivacyConfig -> IO PrivacyInfo
getPrivacyInfo config = do
result <- runCommand (privacyPwDumpPath config) []
case result of
Left err -> do
privacyLogF WARNING "pw-dump failed: %s" err
return $ PrivacyInfo []
Right output -> do
let parsed = Aeson.decode (BL.fromStrict $ TE.encodeUtf8 $ T.pack output) :: Maybe [PwObject]
case parsed of
Nothing -> do
privacyLogF WARNING "Failed to parse pw-dump output" ("" :: String)
return $ PrivacyInfo []
Just objects -> do
let nodes = mapMaybe (toPrivacyNode config) objects
filtered = filterNodes config nodes
-- Remove duplicates based on app name and node type
unique = nubBy (\a b -> appName a == appName b && nodeType a == nodeType b) filtered
return $ PrivacyInfo unique
-- | Convert a PipeWire object to a PrivacyNode if relevant.
toPrivacyNode :: PrivacyConfig -> PwObject -> Maybe PrivacyNode
toPrivacyNode _config obj = do
-- Only process Node type objects
if pwType obj /= "PipeWire:Interface:Node"
then Nothing
else do
info <- pwInfo obj
props <- pwProps info
mediaClass <- propMediaClass props
-- Check if the node is running
let state = pwState info
isRunning = state == Just "running"
if not isRunning
then Nothing
else do
-- Determine node type from media.class
nType <- classToNodeType mediaClass
-- Get application name (try multiple sources)
let name = fromMaybe "Unknown" $
propAppName props
<|> propPortalAppId props
<|> propNodeName props
<|> propMediaName props
-- Get icon name
icon = propAppIconName props
<|> propPortalAppId props
<|> propAppName props
-- Check if it's a monitor stream
monitor = propStreamMonitor props == Just "true"
nName = fromMaybe "" $ propNodeName props
Just PrivacyNode
{ nodeType = nType
, appName = name
, appIcon = icon
, nodeName = nName
, isMonitor = monitor
}
where
(<|>) :: Maybe a -> Maybe a -> Maybe a
(<|>) ma mb = case ma of
Nothing -> mb
just -> just
-- | Map media.class to NodeType.
classToNodeType :: Text -> Maybe NodeType
classToNodeType cls
| "Stream/Input/Audio" `T.isInfixOf` cls = Just AudioInput
| "Audio/Source" `T.isInfixOf` cls = Just AudioInput
| "Stream/Output/Audio" `T.isInfixOf` cls = Just AudioOutput
| "Audio/Sink" `T.isInfixOf` cls = Just AudioOutput
| "Video/Source" `T.isInfixOf` cls = Just VideoInput
| "Stream/Input/Video" `T.isInfixOf` cls = Just VideoInput
| otherwise = Nothing
-- | Filter nodes based on configuration.
filterNodes :: PrivacyConfig -> [PrivacyNode] -> [PrivacyNode]
filterNodes config = filter keep
where
keep node
| privacyIgnoreMonitors config && isMonitor node = False
| privacyIgnoreAudioOutput config && nodeType node == AudioOutput = False
| otherwise = True
-- | State for the privacy info channel.
newtype PrivacyInfoChanVar = PrivacyInfoChanVar
(TChan PrivacyInfo, MVar PrivacyInfo)
-- | Get a broadcast channel for privacy info updates.
--
-- The first call starts a monitoring thread that polls PipeWire at the
-- configured interval. Subsequent calls return the already created channel.
getPrivacyInfoChan :: PrivacyConfig -> TaffyIO (TChan PrivacyInfo)
getPrivacyInfoChan config = do
PrivacyInfoChanVar (chan, _) <- getPrivacyInfoChanVar config
pure chan
-- | Read the current privacy info state.
getPrivacyInfoState :: PrivacyConfig -> TaffyIO PrivacyInfo
getPrivacyInfoState config = do
PrivacyInfoChanVar (_, var) <- getPrivacyInfoChanVar config
liftIO $ readMVar var
getPrivacyInfoChanVar :: PrivacyConfig -> TaffyIO PrivacyInfoChanVar
getPrivacyInfoChanVar config =
getStateDefault $ do
liftIO $ do
chan <- newBroadcastTChanIO
var <- newMVar (PrivacyInfo [])
_ <- forkIO $ monitorPrivacyInfo config chan var
pure $ PrivacyInfoChanVar (chan, var)
monitorPrivacyInfo ::
PrivacyConfig ->
TChan PrivacyInfo ->
MVar PrivacyInfo ->
IO ()
monitorPrivacyInfo config chan var = do
let
intervalMicros :: Int
intervalMicros = max 100000 (floor (privacyPollingInterval config * 1000000))
writeInfo info = do
_ <- swapMVar var info
atomically $ writeTChan chan info
refresh = do
info <- catch (getPrivacyInfo config) $ \(e :: SomeException) -> do
privacyLogF WARNING "Privacy info refresh failed: %s" e
return $ PrivacyInfo []
writeInfo info
-- Initial refresh
refresh
-- Polling loop
forever $ do
threadDelay intervalMicros
refresh