packages feed

hodatime-1.0.0.0: platform/windows/Data/HodaTime/TimeZone/Platform.hs

{-# LANGUAGE ForeignFunctionInterface #-}
module Data.HodaTime.TimeZone.Platform
(
   loadUTC
  ,loadLocalZone
  ,loadTimeZone
  ,loadAvailableZones
)
where

import Data.HodaTime.TimeZone.Internal
import Data.HodaTime.Instant.Internal (Instant(..), bigBang, minus)
import Data.HodaTime.Offset.Internal (Offset(..))
import Data.HodaTime.Duration.Internal (fromNanoseconds)
import Data.HodaTime.Calendar.Gregorian.Internal (yearMonthDayToDays)

import Data.Char (isDigit)
import Data.List (sortOn, foldl')
import Control.Monad (forM)
import Control.Exception (bracket, try, SomeException)
import Data.Int (Int32)
import System.Win32.Types (LONG, HKEY, peekTString, withTString)
import System.Win32.Registry
import System.Win32.Time (SYSTEMTIME(..))
import System.Win32.DLL (loadLibrary, getProcAddress)
import System.IO.Unsafe (unsafePerformIO)
import Foreign.Marshal.Alloc (allocaBytes, alloca)
import Foreign.Marshal.Array (allocaArray)
import Foreign.Storable (sizeOf, Storable(..))
import Foreign.Ptr (castPtr, castPtrToFunPtr, Ptr, FunPtr)
import Foreign.C.Types (CWchar)
import Foreign.C.String (CString, withCString)

-- ICU-based conversion between IANA and Windows zone ids.  Windows uses its own registry zone names (e.g.
-- @"W. Europe Standard Time"@) while the rest of the world uses IANA ids (e.g. @"Europe/Zurich"@).  Rather than
-- vendoring a CLDR windowsZones table (which Microsoft explicitly advises against, since time zones change often and
-- they maintain the data), we delegate to ICU, which has shipped with Windows since Windows 10 v1703 (build 15063)
-- and is kept current by Windows Update.  We load @icu.dll@ dynamically (there is no import library for the
-- system copy, so it cannot be linked against) and resolve the two conversion functions at runtime.  On systems
-- where @icu.dll@ or the symbols are unavailable (e.g. Windows before v1703) the conversions return 'Nothing' and we
-- fall back to treating names as Windows registry names, i.e. the pre-ICU behaviour.
type GetWindowsTimeZoneID = Ptr CWchar -> Int32 -> Ptr CWchar -> Int32 -> Ptr Int32 -> IO Int32
type GetTimeZoneIDForWindowsID = Ptr CWchar -> Int32 -> CString -> Ptr CWchar -> Int32 -> Ptr Int32 -> IO Int32

foreign import ccall unsafe "dynamic"
  mkGetWindowsTimeZoneID :: FunPtr GetWindowsTimeZoneID -> GetWindowsTimeZoneID

foreign import ccall unsafe "dynamic"
  mkGetTimeZoneIDForWindowsID :: FunPtr GetTimeZoneIDForWindowsID -> GetTimeZoneIDForWindowsID

-- | The two ICU zone-conversion functions, resolved once from @icu.dll@.  'Nothing' when ICU is unavailable.
{-# NOINLINE icuZoneFns #-}
icuZoneFns :: Maybe (GetWindowsTimeZoneID, GetTimeZoneIDForWindowsID)
icuZoneFns = unsafePerformIO $ do
  r <- try loadFns :: IO (Either SomeException (GetWindowsTimeZoneID, GetTimeZoneIDForWindowsID))
  return $ either (const Nothing) Just r
  where
    loadFns = do
      hmod <- loadLibrary "icu.dll"
      p1 <- getProcAddress hmod "ucal_getWindowsTimeZoneID"
      p2 <- getProcAddress hmod "ucal_getTimeZoneIDForWindowsID"
      return (mkGetWindowsTimeZoneID (castPtrToFunPtr p1), mkGetTimeZoneIDForWindowsID (castPtrToFunPtr p2))

-- | Output buffer size in UChar units for ICU zone-id conversions; zone ids are far shorter than this.
icuBufLen :: Int
icuBufLen = 128

-- | Translate an IANA zone id (e.g. @"Europe/Zurich"@) to its Windows registry zone name via ICU.  Returns 'Nothing'
--   when the argument is not a known IANA id (for instance it is already a Windows name) or ICU is unavailable.
ianaToWindowsZone :: String -> IO (Maybe String)
ianaToWindowsZone iana = case icuZoneFns of
  Nothing -> return Nothing
  Just (getWindowsTZ, _) ->
    withTString iana $ \pIana ->
    allocaArray icuBufLen $ \pOut ->
    alloca $ \pStatus -> do
      poke pStatus 0
      n <- getWindowsTZ pIana (-1) pOut (fromIntegral icuBufLen) pStatus
      st <- peek pStatus
      if st <= 0 && n > 0 then Just <$> peekTString pOut else return Nothing

-- | Translate a Windows registry zone name to its canonical IANA zone id (CLDR region @"001"@) via ICU.  Returns
--   'Nothing' when the argument is not a known Windows name or ICU is unavailable.
windowsZoneToIana :: String -> IO (Maybe String)
windowsZoneToIana win = case icuZoneFns of
  Nothing -> return Nothing
  Just (_, getTZForWindows) ->
    withTString win $ \pWin ->
    withCString "001" $ \pRegion ->
    allocaArray icuBufLen $ \pOut ->
    alloca $ \pStatus -> do
      poke pStatus 0
      n <- getTZForWindows pWin (-1) pRegion pOut (fromIntegral icuBufLen) pStatus
      st <- peek pStatus
      if st <= 0 && n > 0 then Just <$> peekTString pOut else return Nothing


-- | Accept either a Windows registry zone name or an IANA id.  If ICU recognises the argument as an IANA id we use
--   the matching Windows name; otherwise we assume it is already a Windows name (or ICU is unavailable).
resolveWindowsZone :: String -> IO String
resolveWindowsZone zone = maybe zone id <$> ianaToWindowsZone zone

data REG_TZI_FORMAT = REG_TZI_FORMAT
  {
     _tziBias :: LONG
    ,_tziStandardBias :: LONG
    ,_tziDaylightBias :: LONG
    ,_tziStandardDate :: SYSTEMTIME
    ,_tziDaylightDate :: SYSTEMTIME
  }
  deriving (Show,Eq,Ord)

instance Storable REG_TZI_FORMAT where
  sizeOf _ = sizeOf (undefined :: LONG) * 3 + sizeOf (undefined :: SYSTEMTIME) * 2
  alignment _ = 4

  poke _ _ = error "poke not implemented"

  peek buf = REG_TZI_FORMAT
    <$> peekByteOff buf 0
    <*> peekByteOff buf 4
    <*> peekByteOff buf 8
    <*> peekByteOff buf 12
    <*> peekByteOff buf (12 + sizeOf (undefined :: SYSTEMTIME))

loadUTC :: IO (UtcTransitionsMap, CalDateTransitionsMap)
loadUTC = loadTimeZone "UTC"

loadLocalZone :: IO (UtcTransitionsMap, CalDateTransitionsMap, String)
loadLocalZone = do
  winZone <- readLocalZoneName
  (utcM, calDateM) <- loadTimeZone winZone
  ianaName <- windowsZoneToIana winZone
  return (utcM, calDateM, maybe winZone id ianaName)

loadTimeZone :: String -> IO (UtcTransitionsMap, CalDateTransitionsMap)
loadTimeZone "UTC" = return (utcM, calDateM)
  where
    (utcM, calDateM, _) = fixedOffsetZone "UTC" (Offset 0)
loadTimeZone zone = do
  winZone <- resolveWindowsZone zone
  (stdAbbr, dstAbbr, tzi) <- readTziForZone winZone
  dynTzis <- readDynamicDstForZone winZone
  return $ mkZoneMaps stdAbbr dstAbbr tzi dynTzis

loadAvailableZones :: IO [String]
loadAvailableZones = readAllZoneNames

-- conversion from Windows types

mkZoneMaps :: String -> String -> REG_TZI_FORMAT -> [(Int, REG_TZI_FORMAT)] -> (UtcTransitionsMap, CalDateTransitionsMap)
mkZoneMaps stdAbbr dstAbbr defaultTzi dynTzis = (utcMap, calDateMap')
  where
    dynTzis' = sortOn fst dynTzis
    getInitialExpr [] = tziToExprInfo defaultTzi
    getInitialExpr ((_, tzi):_) = tziToExprInfo tzi
    tl [] = []
    tl (_:xs) = xs
    initialExpr = getInitialExpr dynTzis'
    initialUtcM = addUtcTransitionExpression bigBang initialExpr emptyUtcTransitions
    calDateMap' = addCalDateTransitionExpression lastEntry Largest lastExpr calDateMap
    (utcMap, calDateMap, lastEntry, lastExpr) = foldl' go (initialUtcM, emptyCalDateTransitions, Smallest, initialExpr) $ tl dynTzis'
    go (utcM, calDateM, prevEntry, prevExpr) (y, tzi) = (utcM', calDateM', Entry tran, expr)
      where
        expr = tziToExprInfo tzi
        m = toEnum 0
        tran = Instant (fromIntegral $ yearMonthDayToDays y m 1) 0 0
        utcM' = addUtcTransitionExpression tran expr utcM
        calDateM' = addCalDateTransitionExpression prevEntry before prevExpr calDateM
        before = Entry . flip minus (fromNanoseconds 1) $ tran
    tziToExprInfo (REG_TZI_FORMAT bias stdBias dstBias end start) = TransitionExpressionInfo startExpr endExpr stdTI dstTI
      where
        startExpr = systemTimeToNthDayExpression start stdOffSecs
        endExpr = systemTimeToNthDayExpression end dstOffSecs
        stdTI = TransitionInfo (Offset stdOffSecs) False stdAbbr
        dstTI = TransitionInfo (Offset dstOffSecs) True dstAbbr
        stdOffSecs = 60 * (negate . fromIntegral $ bias + stdBias)
        dstOffSecs = stdOffSecs + 60 * (negate . fromIntegral $ dstBias)

-- TODO: When wYear (the first, discarded field) is non-zero the SYSTEMTIME is an absolute one-time transition, not a
--       recurring yearly pattern.  We currently always treat it as the recurring (NthDay) form, which mis-reads those
--       explicit transitions.  Handle the wYear /= 0 case as an explicit transition instead.
systemTimeToNthDayExpression :: SYSTEMTIME -> Int -> TransitionExpression
systemTimeToNthDayExpression (SYSTEMTIME _ m d nth h mm s _) offsetSecs = NthDayExpression (fromIntegral m - 1) (adjust . fromIntegral $ nth) (fromIntegral d) s''
  where
    adjust 5 = -1                     -- In the registry, 5 actually means last which is -1 for us
    adjust n = n - 1                  -- Switch start nth to zero based
    s'' = s' - offsetSecs             -- Windows times are always local, so convert back to UTC
    s' = h' + mm' + fromIntegral s
    h' = fromIntegral h * 60 * 60
    mm' = fromIntegral mm * 60

readLocalZoneName :: IO String
readLocalZoneName =
  bracket op regCloseKey $ \key ->
  regQueryValueString key "TimeZoneKeyName"
    where
      op = regOpenKeyEx hKEY_LOCAL_MACHINE hive kEY_QUERY_VALUE
      hive = "SYSTEM\\CurrentControlSet\\Control\\TimeZoneInformation"

readAllZoneNames :: IO [String]
readAllZoneNames =
  bracket op regCloseKey $ \key ->
  regEnumKeys key
    where
      op = regOpenKeyEx hKEY_LOCAL_MACHINE hive kEY_READ
      hive = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones"

readTziForZone :: String -> IO (String, String, REG_TZI_FORMAT)
readTziForZone zone =
  bracket op regCloseKey $ \key -> do
    std <- regQueryValueString key "Std"
    dst <- regQueryValueString key "Dlt"
    tzi <- readTzi key "TZI"
    return (std, dst, tzi)
    where
      op = regOpenKeyEx hKEY_LOCAL_MACHINE hive kEY_QUERY_VALUE
      hive = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones\\" ++ zone

readDynamicDstForZone :: String -> IO [(Int, REG_TZI_FORMAT)]
readDynamicDstForZone zone = do
  hasDyn <- hasDynDst
  if hasDyn
  then 
    bracket (op dynDstHive) regCloseKey $ \dstKey -> do
      vals <- regEnumKeyVals dstKey
      let years = foldr toYear [] vals
      forM years $ \y -> do
        tzi <- readTzi dstKey y
        return (read y, tzi)
  else return []
  where
    hasDynDst = bracket (op tzHive) regCloseKey $ \key -> do
                  dyn <- regEnumKeys key
                  return $ length dyn == 1 && dyn !! 0 == "Dynamic DST"
    toYear (y, _, _) xs | all isDigit y = y:xs
                        | otherwise = xs
    op hive = regOpenKeyEx hKEY_LOCAL_MACHINE hive kEY_READ
    tzHive = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones\\" ++ zone
    dynDstHive = tzHive ++ "\\Dynamic DST"

readTzi :: HKEY -> String -> IO REG_TZI_FORMAT
readTzi key p =
  allocaBytes sz $ \ptr -> do
    rvt <- regQueryValueEx key p ptr sz
    verifyAndPeak rvt ptr
  where
    sz = sizeOf (undefined :: REG_TZI_FORMAT)
    verifyAndPeak rvt ptr
        | rvt == rEG_BINARY = peek . castPtr $ ptr
        | otherwise         = error $ "registry corrupt: TZI variable was non-binary type: " ++ show rvt

-- | Read a named REG_SZ (or REG_EXPAND_SZ) value as a 'String'.  Note: 'regQueryValue' reads the /default/ value of a
--   subkey, not a named value, so named string values (Std, Dlt, TimeZoneKeyName) must go through 'regQueryValueEx'.
regQueryValueString :: HKEY -> String -> IO String
regQueryValueString key name =
  allocaBytes sz $ \ptr -> do
    rvt <- regQueryValueEx key name ptr sz
    if rvt == rEG_SZ || rvt == rEG_EXPAND_SZ
      then peekTString (castPtr ptr)
      else error $ "registry corrupt: " ++ name ++ " was non-string type: " ++ show rvt
  where
    sz = 512