packages feed

kmonad-0.4.5: src/KMonad/Keyboard/IO/Linux/DeviceSource.hs

{-# LANGUAGE CPP            #-}
{-# LANGUAGE DeriveAnyClass #-}
{-|
Module      : KMonad.Keyboard.IO.Linux.DeviceSource
Description : Load and acquire a linux /dev/input device
Copyright   : (c) David Janssen, 2019
License     : MIT
Maintainer  : janssen.dhj@gmail.com
Stability   : experimental
Portability : portable

-}
module KMonad.Keyboard.IO.Linux.DeviceSource
  ( deviceSource
  , deviceSource64

  , KeyEventParser
  , decode64
  )
where

import KMonad.Prelude
import Foreign.C.Types
import Foreign.C.Error
import System.Posix
import System.IO.Error

import KMonad.Keyboard.IO.Linux.Types
import KMonad.Util

import qualified Data.Serialize as B (decode)
import qualified RIO.ByteString as B

import System.INotify
import RIO.Directory
import RIO.FilePath

import GHC.IO.Exception (IOException(IOError, ioe_errno))

--------------------------------------------------------------------------------
-- $err

data DeviceSourceError
  = IOCtlGrabError    IOError
  | IOCtlReleaseError IOError
  | PathTypeMismatch  Bool FilePath
  | RootDirDoesNotExist FilePath
  | KeyIODecodeError  String
  deriving Exception

instance Show DeviceSourceError where
  show (IOCtlGrabError e)      = show e
  show (IOCtlReleaseError e)   = show e
  show (PathTypeMismatch d pt) = "Path exists but is not a " <> (if d then "directory" else "file") <> ": " <> pt
  show (RootDirDoesNotExist dev) = "Root directory for device '" <> dev <> "' does not exist"
  show (KeyIODecodeError msg)  = "KeyEvent decode failed with msg: "    <> msg

makeClassyPrisms ''DeviceSourceError

--------------------------------------------------------------------------------
-- $ffi
foreign import ccall "ioctl_keyboard"
  c_ioctl_keyboard :: Fd -> CInt -> IO CInt

-- | Perform an IOCTL operation on an open keyboard handle
ioctl_keyboard :: MonadIO m
  => Fd        -- ^ Descriptor to open keyboard file (like /dev/input/eventXX)
  -> FilePath  -- ^ FilePath to keyboard for error reporting
  -> Bool      -- ^ True to grab, False to ungrab
  -> m ()      -- ^ Return the exit code
ioctl_keyboard h pt g = liftIO $ do
  throwErrnoPathIfMinus1_
    ("Could not perform IOCTL " ++ if g then "grab" else "release")
    pt
    (c_ioctl_keyboard h $ if g then 1 else 0)


--------------------------------------------------------------------------------
-- $decoding

-- | A 'KeyEventParser' describes how to read and parse 'LinuxKeyEvent's from
-- the binary data-stream provided by the device-file.
data KeyEventParser = KeyEventParser
  { _nbytes :: !Int
    -- ^ Size of 1 input event in bytes
  , _prs    :: !(B.ByteString -> Either String LinuxKeyEvent)
    -- ^ Function to convert bytestring to event
  }
makeClassy ''KeyEventParser

-- | Default configuration for parsing keyboard events
defEventParser :: KeyEventParser
defEventParser = KeyEventParser 24 decode64

-- | The KeyEventParser that works on my 64-bit Linux environment
decode64 :: B.ByteString -> Either String LinuxKeyEvent
decode64 bs = linuxKeyEvent . fliptup <$> result
  where
    result :: Either String (Int32, Word16, Word16, Word64, Word64)
    result = B.decode . B.reverse $ bs

    fliptup (a, b, c, d, e) = (e, d, c, b, a)


--------------------------------------------------------------------------------
-- $types

-- | Configurable components of a DeviceSource
data DeviceSourceCfg = DeviceSourceCfg
  { _pth     :: !FilePath        -- ^ Path to the event-file
  , _parser  :: !KeyEventParser  -- ^ The method used to decode events
  , _ignmis  :: !Bool            -- ^ Whether to wait for keyboard to (re-)appear
  }
makeClassy ''DeviceSourceCfg

-- | Collection of data used to read from linux input.h event stream
data DeviceFile = DeviceFile
  { _cfg :: !DeviceSourceCfg -- ^ Configuration settings
  , _dev :: !(IORef (Fd, Handle)) -- ^ Posix and Haskell filedescriptor to the device file
  }
makeClassy ''DeviceFile

instance HasDeviceSourceCfg DeviceFile where deviceSourceCfg = cfg
instance HasKeyEventParser  DeviceFile where keyEventParser  = cfg.parser

-- | Open a device file
deviceSource :: HasLogFunc e
  => KeyEventParser -- ^ The method by which to read and decode events
  -> FilePath    -- ^ The filepath to the device file
  -> Bool           -- ^ Whether to wait for keyboard to (re-)appear
  -> RIO e (Acquire KeySource)
deviceSource pr pt im = mkKeySource (lsOpen pr pt im) lsClose lsRead

-- | Open a device file on a standard linux 64 bit architecture
deviceSource64 :: HasLogFunc e
  => FilePath  -- ^ The filepath to the device file
  -> Bool           -- ^ Whether to wait for keyboard to (re-)appear
  -> RIO e (Acquire KeySource)
deviceSource64 = deviceSource defEventParser


--------------------------------------------------------------------------------
-- $io

-- | Open the keyboard, perform an ioctl grab and return the device handles. This
-- can throw an 'IOException' if the file cannot be opened for reading, or an
-- 'IOCtlGrabError' if an ioctl grab could not be properly performed.
lsOpen' :: HasLogFunc e => FilePath -> Bool -> RIO e (Fd, Handle)
lsOpen' pt im = do
  when im waitForDeviceToExists

  fd <- liftIO $ openFd pt
    ReadOnly
#if !MIN_VERSION_unix(2,8,0)
    Nothing
#endif
    defaultFileFlags
  hd <- liftIO $ fdToHandle fd
  logInfo "Initiating ioctl grab"
  ioctl_keyboard fd pt True `catch` (throwIO . IOCtlGrabError)
  return (fd, hd)
 where
  waitForDeviceToExists = do
    lf <- view logFuncL
    liftIO $ waitForPath lf False pt Nothing

  -- Test for existence followed by a inotify_add_watch
  waitForPath lf isDir pt' inot = do
    ptExists <- doesPathExist pt'
    unless ptExists $ case inot of
      Just inot' -> waitForPath' lf isDir pt' inot'
      Nothing -> withINotify $ \inot' -> do
        runRIO lf $ logInfo "Listening for device"
        waitForPath' lf isDir pt' inot'
        runRIO lf $ logInfo "Found device"
    let doesExistWithType = if isDir then doesDirectoryExist else doesFileExist
    foundWithType <- doesExistWithType pt'
    unless foundWithType . throwIO $ PathTypeMismatch isDir pt'

  -- Wait for parent path and add INotify watch
  waitForPath' lf isDir pt' inot = do
    let parent = takeDirectory pt'
    when (parent == pt') . throwIO $ RootDirDoesNotExist pt
    waitForPath lf True parent $ Just inot
    fn <- B.fromFilePath $ takeFileName pt'
    dir <- B.fromFilePath parent
    block <- newEmptyMVar
    runRIO lf $ logDebug $ "Waiting for path: " <> fromString pt'

    let onFound isDir' = do
          runRIO lf . logDebug $ "Found path: " <> fromString pt'
          unless (isDir == isDir') . throwIO $ PathTypeMismatch isDir pt'
          putMVar block True

    watch <- addWatch inot [Create, MoveIn, DeleteSelf] dir $ \case
      Created isDir' fn'   | fn' == fn -> onFound isDir'
      -- Some symlinks are created then renamed
      MovedIn isDir' fn' _ | fn' == fn -> onFound isDir'
      DeletedSelf -> do
        runRIO lf . logDebug $ "Parent directory deleted: " <> fromString parent
        putMVar block False
      _ -> pure ()

    -- When creating symlinks in `by-id` or `by-path` the folder and symlink are
    -- created in quick succession
    pathCreatedDuringWatchInit <- doesPathExist pt'
    found <- if pathCreatedDuringWatchInit
      then pure True
      else takeMVar block
    if found
      then removeWatch watch
      else waitForPath lf isDir pt' $ Just inot


-- | Like `lsOpen'` but wrap it in a full 'DeviceFile'.
lsOpen :: (HasLogFunc e)
  => KeyEventParser   -- ^ The method by which to decode events
  -> FilePath      -- ^ The path to the device file
  -> Bool             -- ^ Whether to wait for keyboard to (re-)appear
  -> RIO e DeviceFile
lsOpen pr pt im = DeviceFile (DeviceSourceCfg pt pr im) <$> (newIORef =<< lsOpen' pt im)

-- | Release the ioctl grab and close the device file. This can throw an
-- 'IOException' if the handle to the device cannot be properly closed, or an
-- 'IOCtlReleaseError' if the ioctl release could not be properly performed.
lsClose :: (HasLogFunc e) => DeviceFile -> RIO e ()
lsClose src = do
  (fd, hdl) <- readIORef (src^.dev)
  logInfo "Releasing ioctl grab"
  ioctl_keyboard fd (src^.cfg.pth) False `catch` (throwIO . IOCtlReleaseError)
  hClose hdl

-- | Read a bytestring from an open filehandle and return a parsed event. This
-- can throw a 'KeyIODecodeError' if reading from the 'DeviceFile' fails to
-- yield a parseable sequence of bytes.
lsRead :: (HasLogFunc e) => DeviceFile -> RIO e KeyEvent
lsRead src = do
  bts <- lsRead' =<< readIORef (src^.dev)
  case src^.prs $ bts of
    Right p -> case fromLinuxKeyEvent p of
      Just e  -> return e
      Nothing -> lsRead src
    Left s -> throwIO $ KeyIODecodeError s
 where
  lsRead' (_, hdl) =
    tryJust isENODEV (B.hGet hdl (src^.nbytes)) >>= \case
      Right bts -> pure bts
      Left e -> do
        devExists <- doesFileExist (src^.cfg.pth)
        hClose hdl
        when devExists $ logRethrow "Device still exists, but reading failed" (toException e)
        logInfo "Device disconnected"
        h <- lsOpen' (src^.cfg.pth) (src^.ignmis)
        writeIORef (src^.dev) h
        logInfo "Device reconnected"
        lsRead' h
  isENODEV e@IOError{ioe_errno = Just errno}
    | src^.ignmis && Errno errno == eNODEV = Just e
  isENODEV _ = Nothing