packages feed

hledger-flow-0.19.0: src/Hledger/Flow/ReleaseNotice.hs

{-# LANGUAGE OverloadedStrings #-}

module Hledger.Flow.ReleaseNotice
  ( withReleaseNotice, startCheck, finishCommand, checkRelease
  ) where

import Control.Concurrent.Async (async, poll)
import Control.Exception (catch, evaluate, throwIO, try)
import Control.Monad (guard, join)
import Data.Aeson (decode, decodeFileStrict, withObject, (.:), (.:?), (.!=))
import Data.Aeson.Types (parseMaybe)
import qualified Data.ByteString.Lazy as BL
import Data.Char (isDigit)
import Data.Maybe (fromMaybe, listToMaybe)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import Data.Version (Version, parseVersion, showVersion)
import Network.HTTP.Client (HttpException, checkResponse, httpLbs, parseRequest, requestHeaders, responseBody, throwErrorStatusCodes)
import Network.HTTP.Client.TLS (getGlobalManager)
import Paths_hledger_flow (version)
import System.Directory (canonicalizePath)
import System.Environment (getExecutablePath, lookupEnv)
import System.Exit (ExitCode)
import System.FilePath ((</>), takeDirectory, takeFileName)
import System.IO (hIsTerminalDevice, stderr, stdout)
import System.IO.Error (catchIOError)
import System.Timeout (timeout)
import Text.ParserCombinators.ReadP (eof, readP_to_S)

withReleaseNotice :: IO () -> IO ()
withReleaseNotice command = do
  finish <- setup `catchIOError` const (pure (pure ()))
  finishCommand finish command
  where
    setup = do
      disabled <- lookupEnv "HLEDGER_FLOW_NO_UPDATE_CHECK"
      outTTY <- hIsTerminalDevice stdout
      errTTY <- hIsTerminalDevice stderr
      if disabled /= Just "1" && outTTY && errTTY
        then startCheck (checkRelease fetch getExecutablePath)
        else pure (pure ())

-- The worker belongs to this CLI invocation. Do not join it or synchronously
-- cancel it on exit: either would put network cleanup on the command's path.
startCheck :: IO (Maybe T.Text) -> IO (IO ())
startCheck check = do
  worker <- async $ do
    notice <- check
    traverse evaluate notice
  pure $ do
    result <- poll worker
    case result of
      Just (Right (Just notice)) -> T.hPutStrLn stderr notice
      _ -> pure ()

-- The caller has already rendered ordinary errors as diagnostics + ExitCode.
-- Catch just ExitCode here so that interrupts skip the notice altogether.
finishCommand :: IO () -> IO () -> IO ()
finishCommand finish command = do
  outcome <- try command :: IO (Either ExitCode ())
  finish `catchIOError` const (pure ())
  either throwIO pure outcome

fetch :: String -> IO (Maybe BL.ByteString)
fetch url = request `catch` ignoreHttpError
  where
    request = do
      manager <- getGlobalManager
      req <- parseRequest url
      let githubRequest = req { requestHeaders = [("User-Agent", "hledger-flow")]
                              , checkResponse = throwErrorStatusCodes }
      response <- httpLbs githubRequest manager
      pure (Just (responseBody response))
    ignoreHttpError :: HttpException -> IO (Maybe BL.ByteString)
    ignoreHttpError _ = pure Nothing

checkRelease :: (String -> IO (Maybe BL.ByteString)) -> IO FilePath -> IO (Maybe T.Text)
checkRelease get executable = do
  result <- timeout 3000000 (get "https://api.github.com/repos/apauley/hledger-flow/releases/latest")
  case join result >>= releaseVersion of
    Just latest | latest > version -> do
      confirmed <- timeout 1000000 (homebrewOffers latest `catchIOError` const (pure False))
      let advice = if confirmed == Just True
            then "Upgrade with: brew update && brew upgrade ferntrail/tap/hledger-flow"
            else "https://github.com/apauley/hledger-flow/releases/latest"
      pure $ Just $ "hledger-flow " <> render latest <> " is available (installed "
                 <> render version <> ").\n" <> advice
    _ -> pure Nothing
  where
    render = T.pack . showVersion
    homebrewOffers latest = do
      path <- executable >>= canonicalizePath
      owned <- ownsHomebrewBinary path
      if owned then do
        formula <- get "https://raw.githubusercontent.com/FernTrail/homebrew-tap/main/Formula/hledger-flow.rb"
        let tagLine = "tag = \"v" <> render latest <> "\""
        pure $ fromMaybe False $ do
          body <- formula
          contents <- either (const Nothing) Just (T.decodeUtf8' (BL.toStrict body))
          pure $ tagLine `elem` map T.strip (T.lines contents)
      else pure False

releaseVersion :: BL.ByteString -> Maybe Version
releaseVersion bytes = do
  value <- decode bytes
  tag <- parseMaybe (withObject "release" $ \o -> do
    draft <- o .:? "draft" .!= False
    prerelease <- o .:? "prerelease" .!= False
    guard (not draft && not prerelease)
    o .: "tag_name") value
  number <- T.stripPrefix "v" tag
  guard (T.all (\c -> isDigit c || c == '.') number)
  listToMaybe [v | (v, "") <- readP_to_S (parseVersion <* eof) (T.unpack number)]

ownsHomebrewBinary :: FilePath -> IO Bool
ownsHomebrewBinary path
  | map takeFileName [path, bin, rack, takeDirectory rack] /=
      ["hledger-flow", "bin", "hledger-flow", "Cellar"] = pure False
  | otherwise = do
      receipt <- decodeFileStrict (keg </> "INSTALL_RECEIPT.json")
      let tap = receipt >>= parseMaybe (withObject "receipt" $ \o ->
                  o .: "source" >>= withObject "source" (.: "tap"))
      pure $ fmap T.toCaseFold tap == Just "ferntrail/tap"
  where
    bin = takeDirectory path
    keg = takeDirectory bin
    rack = takeDirectory keg