Use Chrome favicons in taffybar workspaces
This commit is contained in:
320
dotfiles/config/taffybar/TaffybarConfig/ChromeFavicons.hs
Normal file
320
dotfiles/config/taffybar/TaffybarConfig/ChromeFavicons.hs
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
{-# LANGUAGE LambdaCase #-}
|
||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE TypeApplications #-}
|
||||||
|
|
||||||
|
module TaffybarConfig.ChromeFavicons
|
||||||
|
( ChromeFaviconOverlayMode (..),
|
||||||
|
ChromeFaviconConfig (..),
|
||||||
|
defaultChromeFaviconConfig,
|
||||||
|
chromeFaviconIconGetter,
|
||||||
|
)
|
||||||
|
where
|
||||||
|
|
||||||
|
import Control.Exception (IOException, try)
|
||||||
|
import Control.Monad (unless, when)
|
||||||
|
import Control.Monad.IO.Class (liftIO)
|
||||||
|
import Data.Char (isAlphaNum)
|
||||||
|
import Data.Int (Int32)
|
||||||
|
import Data.Maybe (fromMaybe, listToMaybe)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import qualified GI.GdkPixbuf.Enums as GdkPixbuf
|
||||||
|
import qualified GI.GdkPixbuf.Objects.Pixbuf as Gdk
|
||||||
|
import System.Directory
|
||||||
|
( createDirectoryIfMissing,
|
||||||
|
doesFileExist,
|
||||||
|
getFileSize,
|
||||||
|
renameFile,
|
||||||
|
)
|
||||||
|
import System.Environment.XDG.BaseDir (getUserCacheDir)
|
||||||
|
import System.Exit (ExitCode (ExitSuccess))
|
||||||
|
import System.FilePath ((</>))
|
||||||
|
import System.Process (readProcessWithExitCode)
|
||||||
|
import qualified System.Taffybar.Information.Workspaces.Model as WorkspaceModel
|
||||||
|
import qualified System.Taffybar.Widget.Workspaces as Workspaces
|
||||||
|
import System.Taffybar.Widget.Util (loadPixbufByName)
|
||||||
|
|
||||||
|
data ChromeFaviconOverlayMode
|
||||||
|
= FaviconWithChromeOverlay
|
||||||
|
| ChromeWithFaviconOverlay
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
data ChromeFaviconConfig = ChromeFaviconConfig
|
||||||
|
{ chromeFaviconOverlayMode :: ChromeFaviconOverlayMode,
|
||||||
|
chromeFaviconOverlayRatio :: Double,
|
||||||
|
chromeFaviconEnabled :: Bool
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
defaultChromeFaviconConfig :: ChromeFaviconConfig
|
||||||
|
defaultChromeFaviconConfig =
|
||||||
|
ChromeFaviconConfig
|
||||||
|
{ chromeFaviconOverlayMode = FaviconWithChromeOverlay,
|
||||||
|
chromeFaviconOverlayRatio = 0.45,
|
||||||
|
chromeFaviconEnabled = True
|
||||||
|
}
|
||||||
|
|
||||||
|
data BridgePayload = BridgePayload
|
||||||
|
{ payloadMappedWindowId :: Text,
|
||||||
|
payloadFaviconURL :: Text
|
||||||
|
}
|
||||||
|
deriving (Eq, Show)
|
||||||
|
|
||||||
|
chromeFaviconIconGetter :: ChromeFaviconConfig -> Workspaces.WindowIconPixbufGetter
|
||||||
|
chromeFaviconIconGetter cfg =
|
||||||
|
Workspaces.handleIconGetterException $ \size windowInfo ->
|
||||||
|
if chromeFaviconEnabled cfg && isChromeWindow windowInfo
|
||||||
|
then liftIO $ chromeFaviconPixbuf cfg size windowInfo
|
||||||
|
else pure Nothing
|
||||||
|
|
||||||
|
chromeFaviconPixbuf ::
|
||||||
|
ChromeFaviconConfig ->
|
||||||
|
Int32 ->
|
||||||
|
WorkspaceModel.WindowInfo ->
|
||||||
|
IO (Maybe Gdk.Pixbuf)
|
||||||
|
chromeFaviconPixbuf cfg size windowInfo = do
|
||||||
|
payload <- getChromeWindowInfoPayload windowInfo
|
||||||
|
case payload of
|
||||||
|
Just p
|
||||||
|
| payloadMatchesWindow windowInfo p,
|
||||||
|
validFaviconURL (payloadFaviconURL p) -> do
|
||||||
|
mFavicon <- loadCachedFavicon size (payloadFaviconURL p)
|
||||||
|
mChrome <- runChromeIconGetter size windowInfo
|
||||||
|
case (mFavicon, mChrome) of
|
||||||
|
(Just favicon, Just chromeIcon) ->
|
||||||
|
Just <$> composeChromeFavicon cfg size favicon chromeIcon
|
||||||
|
(Just favicon, Nothing) -> Just <$> scalePixbuf size favicon
|
||||||
|
_ -> pure Nothing
|
||||||
|
_ -> pure Nothing
|
||||||
|
|
||||||
|
runChromeIconGetter :: Int32 -> WorkspaceModel.WindowInfo -> IO (Maybe Gdk.Pixbuf)
|
||||||
|
runChromeIconGetter size _ =
|
||||||
|
loadPixbufByName size "google-chrome"
|
||||||
|
|
||||||
|
payloadMatchesWindow :: WorkspaceModel.WindowInfo -> BridgePayload -> Bool
|
||||||
|
payloadMatchesWindow windowInfo payload =
|
||||||
|
normalizedHyprlandWindowId windowInfo == Just (normalizeAddress (payloadMappedWindowId payload))
|
||||||
|
|
||||||
|
normalizedHyprlandWindowId :: WorkspaceModel.WindowInfo -> Maybe Text
|
||||||
|
normalizedHyprlandWindowId windowInfo =
|
||||||
|
case WorkspaceModel.windowIdentity windowInfo of
|
||||||
|
WorkspaceModel.HyprlandWindowIdentity address -> Just (normalizeAddress address)
|
||||||
|
WorkspaceModel.X11WindowIdentity _ -> Nothing
|
||||||
|
|
||||||
|
isChromeWindow :: WorkspaceModel.WindowInfo -> Bool
|
||||||
|
isChromeWindow windowInfo =
|
||||||
|
any looksLikeChrome (WorkspaceModel.windowClassHints windowInfo)
|
||||||
|
|
||||||
|
looksLikeChrome :: Text -> Bool
|
||||||
|
looksLikeChrome raw =
|
||||||
|
let lowered = T.toLower raw
|
||||||
|
in any (`T.isInfixOf` lowered) ["chrome", "chromium", "brave", "edge", "vivaldi"]
|
||||||
|
|
||||||
|
normalizeAddress :: Text -> Text
|
||||||
|
normalizeAddress address =
|
||||||
|
let trimmed = T.strip address
|
||||||
|
in if "0x" `T.isPrefixOf` trimmed || T.null trimmed
|
||||||
|
then trimmed
|
||||||
|
else "0x" <> trimmed
|
||||||
|
|
||||||
|
validFaviconURL :: Text -> Bool
|
||||||
|
validFaviconURL url =
|
||||||
|
any (`T.isPrefixOf` url) ["https://", "http://"]
|
||||||
|
|
||||||
|
getChromeWindowInfoPayload :: WorkspaceModel.WindowInfo -> IO (Maybe BridgePayload)
|
||||||
|
getChromeWindowInfoPayload windowInfo =
|
||||||
|
case normalizedHyprlandWindowId windowInfo of
|
||||||
|
Just windowId -> do
|
||||||
|
payloads <- getBridgeString "GetWindowPayloads"
|
||||||
|
payload <- case payloads of
|
||||||
|
Just payloadText -> extractBridgePayloadForWindow windowId payloadText
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
case payload of
|
||||||
|
Just value -> pure (Just value)
|
||||||
|
Nothing -> getLastChromeWindowInfoPayload
|
||||||
|
Nothing -> getLastChromeWindowInfoPayload
|
||||||
|
|
||||||
|
getLastChromeWindowInfoPayload :: IO (Maybe BridgePayload)
|
||||||
|
getLastChromeWindowInfoPayload = do
|
||||||
|
payload <- getBridgeString "GetLastPayload"
|
||||||
|
case payload of
|
||||||
|
Just payloadText -> extractBridgePayload payloadText
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
|
||||||
|
getBridgeString :: String -> IO (Maybe String)
|
||||||
|
getBridgeString method = do
|
||||||
|
result <-
|
||||||
|
try @IOException $
|
||||||
|
readProcessWithExitCode
|
||||||
|
"busctl"
|
||||||
|
[ "--user",
|
||||||
|
"call",
|
||||||
|
"org.imalison.ChromeWindowInfo",
|
||||||
|
"/org/imalison/ChromeWindowInfo",
|
||||||
|
"org.imalison.ChromeWindowInfo",
|
||||||
|
method
|
||||||
|
]
|
||||||
|
""
|
||||||
|
case result of
|
||||||
|
Right (ExitSuccess, stdoutText, _) ->
|
||||||
|
pure (parseBusctlString stdoutText)
|
||||||
|
_ -> pure Nothing
|
||||||
|
|
||||||
|
parseBusctlString :: String -> Maybe String
|
||||||
|
parseBusctlString output = do
|
||||||
|
rest <- T.stripPrefix "s " (T.strip (T.pack output))
|
||||||
|
decodeQuotedString (T.unpack rest)
|
||||||
|
|
||||||
|
decodeQuotedString :: String -> Maybe String
|
||||||
|
decodeQuotedString raw =
|
||||||
|
case reads raw of
|
||||||
|
[(decoded, trailing)] | all (`elem` (" \n\t\r" :: String)) trailing -> Just decoded
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
extractBridgePayload :: String -> IO (Maybe BridgePayload)
|
||||||
|
extractBridgePayload payload = do
|
||||||
|
let jqFilter = "[.bridge.mapped_window.window_id // \"\", .tab.favicon_url // \"\"] | @tsv"
|
||||||
|
(code, stdoutText, _) <- readProcessWithExitCode "jq" ["-r", jqFilter] payload
|
||||||
|
pure $
|
||||||
|
case (code, T.splitOn "\t" (T.strip (T.pack stdoutText))) of
|
||||||
|
(ExitSuccess, [mappedWindowId, faviconURL])
|
||||||
|
| not (T.null mappedWindowId),
|
||||||
|
not (T.null faviconURL) ->
|
||||||
|
Just (BridgePayload mappedWindowId faviconURL)
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
extractBridgePayloadForWindow :: Text -> String -> IO (Maybe BridgePayload)
|
||||||
|
extractBridgePayloadForWindow windowId payloads = do
|
||||||
|
let jqFilter = ".[$window_id] // empty | [.bridge.mapped_window.window_id // \"\", .tab.favicon_url // \"\"] | @tsv"
|
||||||
|
(code, stdoutText, _) <-
|
||||||
|
readProcessWithExitCode
|
||||||
|
"jq"
|
||||||
|
["-r", "--arg", "window_id", T.unpack windowId, jqFilter]
|
||||||
|
payloads
|
||||||
|
pure $
|
||||||
|
case (code, T.splitOn "\t" (T.strip (T.pack stdoutText))) of
|
||||||
|
(ExitSuccess, [mappedWindowId, faviconURL])
|
||||||
|
| not (T.null mappedWindowId),
|
||||||
|
not (T.null faviconURL) ->
|
||||||
|
Just (BridgePayload mappedWindowId faviconURL)
|
||||||
|
_ -> Nothing
|
||||||
|
|
||||||
|
loadCachedFavicon :: Int32 -> Text -> IO (Maybe Gdk.Pixbuf)
|
||||||
|
loadCachedFavicon size url = do
|
||||||
|
path <- ensureCachedFavicon url
|
||||||
|
case path of
|
||||||
|
Just faviconPath ->
|
||||||
|
try @IOException (Gdk.pixbufNewFromFileAtScale faviconPath size size True) >>= \case
|
||||||
|
Right (Just pixbuf) -> pure (Just pixbuf)
|
||||||
|
Right Nothing -> pure Nothing
|
||||||
|
Left _ -> pure Nothing
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
|
||||||
|
ensureCachedFavicon :: Text -> IO (Maybe FilePath)
|
||||||
|
ensureCachedFavicon url = do
|
||||||
|
cacheRoot <- getUserCacheDir "taffybar/chrome-favicons"
|
||||||
|
let rawDir = cacheRoot </> "raw"
|
||||||
|
createDirectoryIfMissing True rawDir
|
||||||
|
hash <- hashText url
|
||||||
|
let path = rawDir </> (hash <> faviconExtension url)
|
||||||
|
cached <- nonEmptyFileExists path
|
||||||
|
unless cached $
|
||||||
|
downloadFavicon url path
|
||||||
|
exists <- nonEmptyFileExists path
|
||||||
|
pure $ if exists then Just path else Nothing
|
||||||
|
|
||||||
|
hashText :: Text -> IO String
|
||||||
|
hashText value = do
|
||||||
|
(code, stdoutText, _) <-
|
||||||
|
readProcessWithExitCode "sha256sum" [] (T.unpack value)
|
||||||
|
pure $
|
||||||
|
if code == ExitSuccess
|
||||||
|
then takeWhile (/= ' ') stdoutText
|
||||||
|
else safeFileComponent value
|
||||||
|
|
||||||
|
safeFileComponent :: Text -> String
|
||||||
|
safeFileComponent =
|
||||||
|
take 96 . map normalizeChar . T.unpack
|
||||||
|
where
|
||||||
|
normalizeChar c
|
||||||
|
| isAlphaNum c = c
|
||||||
|
| otherwise = '-'
|
||||||
|
|
||||||
|
faviconExtension :: Text -> String
|
||||||
|
faviconExtension url =
|
||||||
|
fromMaybe ".img" $
|
||||||
|
listToMaybe
|
||||||
|
[ T.unpack ext
|
||||||
|
| ext <- [".svg", ".png", ".ico", ".jpg", ".jpeg", ".webp", ".gif"] :: [Text],
|
||||||
|
ext `T.isSuffixOf` T.toLower pathOnly
|
||||||
|
]
|
||||||
|
where
|
||||||
|
pathOnly = T.takeWhile (/= '?') url
|
||||||
|
|
||||||
|
downloadFavicon :: Text -> FilePath -> IO ()
|
||||||
|
downloadFavicon url path = do
|
||||||
|
let tmp = path <> ".tmp"
|
||||||
|
(code, _, _) <-
|
||||||
|
readProcessWithExitCode
|
||||||
|
"curl"
|
||||||
|
[ "-fsSL",
|
||||||
|
"--max-time",
|
||||||
|
"10",
|
||||||
|
"--retry",
|
||||||
|
"1",
|
||||||
|
"-o",
|
||||||
|
tmp,
|
||||||
|
T.unpack url
|
||||||
|
]
|
||||||
|
""
|
||||||
|
when (code == ExitSuccess) $
|
||||||
|
renameFile tmp path
|
||||||
|
|
||||||
|
nonEmptyFileExists :: FilePath -> IO Bool
|
||||||
|
nonEmptyFileExists path = do
|
||||||
|
exists <- doesFileExist path
|
||||||
|
if exists
|
||||||
|
then (> 0) <$> getFileSize path
|
||||||
|
else pure False
|
||||||
|
|
||||||
|
composeChromeFavicon ::
|
||||||
|
ChromeFaviconConfig ->
|
||||||
|
Int32 ->
|
||||||
|
Gdk.Pixbuf ->
|
||||||
|
Gdk.Pixbuf ->
|
||||||
|
IO Gdk.Pixbuf
|
||||||
|
composeChromeFavicon cfg size favicon chromeIcon = do
|
||||||
|
let (baseSource, overlaySource) =
|
||||||
|
case chromeFaviconOverlayMode cfg of
|
||||||
|
FaviconWithChromeOverlay -> (favicon, chromeIcon)
|
||||||
|
ChromeWithFaviconOverlay -> (chromeIcon, favicon)
|
||||||
|
base <- scalePixbuf size baseSource
|
||||||
|
result <- fromMaybe base <$> Gdk.pixbufCopy base
|
||||||
|
baseWidth <- Gdk.pixbufGetWidth result
|
||||||
|
baseHeight <- Gdk.pixbufGetHeight result
|
||||||
|
let baseSize = max 1 (min baseWidth baseHeight)
|
||||||
|
overlaySize =
|
||||||
|
max 1 $
|
||||||
|
min baseSize $
|
||||||
|
round (fromIntegral baseSize * chromeFaviconOverlayRatio cfg)
|
||||||
|
overlayX = baseWidth - overlaySize
|
||||||
|
overlayY = baseHeight - overlaySize
|
||||||
|
overlay <- scalePixbuf overlaySize overlaySource
|
||||||
|
Gdk.pixbufComposite
|
||||||
|
overlay
|
||||||
|
result
|
||||||
|
overlayX
|
||||||
|
overlayY
|
||||||
|
overlaySize
|
||||||
|
overlaySize
|
||||||
|
(fromIntegral overlayX)
|
||||||
|
(fromIntegral overlayY)
|
||||||
|
1
|
||||||
|
1
|
||||||
|
GdkPixbuf.InterpTypeBilinear
|
||||||
|
255
|
||||||
|
pure result
|
||||||
|
|
||||||
|
scalePixbuf :: Int32 -> Gdk.Pixbuf -> IO Gdk.Pixbuf
|
||||||
|
scalePixbuf size pixbuf =
|
||||||
|
fromMaybe pixbuf <$> Gdk.pixbufScaleSimple pixbuf size size GdkPixbuf.InterpTypeBilinear
|
||||||
@@ -26,6 +26,7 @@ import System.Taffybar.Context
|
|||||||
)
|
)
|
||||||
import System.Taffybar.Information.Memory (MemoryInfo (..), parseMeminfo)
|
import System.Taffybar.Information.Memory (MemoryInfo (..), parseMeminfo)
|
||||||
import qualified System.Taffybar.Information.Workspaces.Hyprland as HyprlandWorkspaces
|
import qualified System.Taffybar.Information.Workspaces.Hyprland as HyprlandWorkspaces
|
||||||
|
import System.Taffybar.Information.ChromeWindowInfo (getChromeWindowInfoRefreshChan)
|
||||||
import System.Taffybar.Util (postGUIASync)
|
import System.Taffybar.Util (postGUIASync)
|
||||||
import System.Taffybar.Widget
|
import System.Taffybar.Widget
|
||||||
import qualified System.Taffybar.Widget.ASUS as ASUS
|
import qualified System.Taffybar.Widget.ASUS as ASUS
|
||||||
@@ -110,6 +111,7 @@ workspacesWidget =
|
|||||||
{ Workspaces.widgetGap = 0,
|
{ Workspaces.widgetGap = 0,
|
||||||
Workspaces.minIcons = 1,
|
Workspaces.minIcons = 1,
|
||||||
Workspaces.getWindowIconPixbuf = workspaceWindowIconGetter,
|
Workspaces.getWindowIconPixbuf = workspaceWindowIconGetter,
|
||||||
|
Workspaces.externalIconRefreshSources = [getChromeWindowInfoRefreshChan],
|
||||||
Workspaces.hyprlandWorkspaceProviderConfig =
|
Workspaces.hyprlandWorkspaceProviderConfig =
|
||||||
HyprlandWorkspaces.defaultHyprlandWorkspaceProviderConfig
|
HyprlandWorkspaces.defaultHyprlandWorkspaceProviderConfig
|
||||||
{ HyprlandWorkspaces.specialWorkspaceWindowTarget =
|
{ HyprlandWorkspaces.specialWorkspaceWindowTarget =
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ import System.Taffybar.Util (getPixbufFromFilePath, maybeTCombine, (<|||>))
|
|||||||
import System.Taffybar.Widget.Util (loadPixbufByName)
|
import System.Taffybar.Widget.Util (loadPixbufByName)
|
||||||
import qualified System.Taffybar.Widget.Workspaces as Workspaces
|
import qualified System.Taffybar.Widget.Workspaces as Workspaces
|
||||||
import System.Taffybar.WindowIcon (pixBufFromColor)
|
import System.Taffybar.WindowIcon (pixBufFromColor)
|
||||||
|
import TaffybarConfig.ChromeFavicons
|
||||||
|
( ChromeFaviconConfig (..),
|
||||||
|
ChromeFaviconOverlayMode (..),
|
||||||
|
chromeFaviconIconGetter,
|
||||||
|
defaultChromeFaviconConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
chromeFaviconConfig :: ChromeFaviconConfig
|
||||||
|
chromeFaviconConfig =
|
||||||
|
defaultChromeFaviconConfig
|
||||||
|
{ chromeFaviconOverlayMode = FaviconWithChromeOverlay,
|
||||||
|
chromeFaviconOverlayRatio = 0.45,
|
||||||
|
chromeFaviconEnabled = True
|
||||||
|
}
|
||||||
|
|
||||||
x11FullWorkspaceNames :: X11Property [(WorkspaceId, String)]
|
x11FullWorkspaceNames :: X11Property [(WorkspaceId, String)]
|
||||||
x11FullWorkspaceNames =
|
x11FullWorkspaceNames =
|
||||||
@@ -174,7 +188,8 @@ workspaceFallbackIcon size _ =
|
|||||||
|
|
||||||
workspaceWindowIconGetter :: Workspaces.WindowIconPixbufGetter
|
workspaceWindowIconGetter :: Workspaces.WindowIconPixbufGetter
|
||||||
workspaceWindowIconGetter =
|
workspaceWindowIconGetter =
|
||||||
workspaceManualIconGetter
|
chromeFaviconIconGetter chromeFaviconConfig
|
||||||
|
<|||> workspaceManualIconGetter
|
||||||
<|||> Workspaces.getWindowIconPixbufFromChrome
|
<|||> Workspaces.getWindowIconPixbufFromChrome
|
||||||
<|||> Workspaces.defaultGetWindowIconPixbuf
|
<|||> Workspaces.defaultGetWindowIconPixbuf
|
||||||
<|||> workspaceFallbackIcon
|
<|||> workspaceFallbackIcon
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ executable taffybar
|
|||||||
hs-source-dirs: .
|
hs-source-dirs: .
|
||||||
main-is: taffybar.hs
|
main-is: taffybar.hs
|
||||||
other-modules: TaffybarConfig.Config
|
other-modules: TaffybarConfig.Config
|
||||||
|
, TaffybarConfig.ChromeFavicons
|
||||||
, TaffybarConfig.Host
|
, TaffybarConfig.Host
|
||||||
, TaffybarConfig.Widgets
|
, TaffybarConfig.Widgets
|
||||||
, TaffybarConfig.WidgetUtil
|
, TaffybarConfig.WidgetUtil
|
||||||
|
|||||||
Submodule dotfiles/config/taffybar/taffybar updated: 2c95fd5a8a...5af1f40e56
Reference in New Issue
Block a user