quickshell-rice/quickshell/services/python/active_window_service.py

228 lines
7.6 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import sys
import glob
import json
import time
import subprocess
import shutil
import configparser
import dbus
import dbus.service
import dbus.mainloop.glib
from gi.repository import GLib
ACTIVE_FILE = "/tmp/quickshell_active_app.txt"
OPEN_WINS_FILE = "/tmp/quickshell_open_windows.json"
_icon_cache = {}
def get_icon_search_dirs():
theme = 'hicolor'
kde_cfg = os.path.expanduser('~/.config/kdeglobals')
if os.path.exists(kde_cfg):
cp = configparser.ConfigParser(interpolation=None)
try:
cp.read(kde_cfg)
t = cp.get('Icons', 'Theme', fallback=None)
if t: theme = t
except Exception: pass
themes_to_search = [theme]
def add_inherits(t_name):
for base in [os.path.expanduser('~/.local/share/icons'), '/usr/share/icons']:
idx = os.path.join(base, t_name, 'index.theme')
if os.path.exists(idx):
cp = configparser.ConfigParser(interpolation=None)
try:
cp.read(idx)
inh = cp.get('Icon Theme', 'Inherits', fallback='')
for parent in inh.split(','):
parent = parent.strip()
if parent and parent not in themes_to_search:
themes_to_search.append(parent)
add_inherits(parent)
except Exception: pass
add_inherits(theme)
for fallback_theme in ['hicolor', 'breeze', 'Papirus', 'Adwaita']:
if fallback_theme not in themes_to_search:
themes_to_search.append(fallback_theme)
dirs = []
bases = [os.path.expanduser('~/.local/share/icons'), '/usr/share/icons']
subdirs = [
'64x64/apps', '48x48/apps', 'scalable/apps', '128x128/apps', '256x256/apps', '32x32/apps',
'64x64@2x/apps', '48x48@2x/apps', 'apps/64', 'apps/48', 'apps/scalable', 'apps'
]
for t in themes_to_search:
for b in bases:
for sd in subdirs:
d = os.path.join(b, t, sd)
if os.path.exists(d) and d not in dirs:
dirs.append(d)
dirs.append('/usr/share/pixmaps')
return dirs
_search_dirs = get_icon_search_dirs()
def parse_desktop(f):
entry = {}
in_desktop = False
try:
with open(f, 'r', encoding='utf-8', errors='ignore') as fp:
for line in fp:
line = line.strip()
if not line or line.startswith('#'): continue
if line.startswith('['):
in_desktop = (line == '[Desktop Entry]')
continue
if in_desktop and '=' in line:
k, v = line.split('=', 1)
k = k.strip()
if k not in entry:
entry[k] = v.strip()
except Exception: pass
return entry
def resolve_app_info(app_id):
if not app_id:
return {"appId": "", "name": "Application", "icon": ""}
app_lower = app_id.lower().strip()
if app_lower in _icon_cache:
return _icon_cache[app_lower]
desktop_files = (
glob.glob('/usr/share/applications/*.desktop') +
glob.glob('/usr/share/applications/**/*.desktop', recursive=True) +
glob.glob(os.path.expanduser('~/.local/share/applications/*.desktop')) +
glob.glob(os.path.expanduser('~/.local/share/applications/**/*.desktop'), recursive=True)
)
icon_name = None
app_name = None
for df in desktop_files:
basename = os.path.basename(df).lower()
base_no_ext = basename.replace('.desktop', '')
if app_lower == base_no_ext or app_lower in basename or base_no_ext in app_lower:
entry = parse_desktop(df)
if entry:
if 'Icon' in entry and not icon_name:
icon_name = entry['Icon']
if 'Name' in entry and not app_name:
app_name = entry['Name']
if icon_name and app_name:
break
if not icon_name:
icon_name = app_lower
if not app_name:
clean = app_lower.split('.')[-1].replace('-', ' ').replace('_', ' ')
app_name = clean.title()
resolved_icon_path = ""
if os.path.isabs(icon_name) and os.path.exists(icon_name):
resolved_icon_path = icon_name
else:
for d in _search_dirs:
if not os.path.exists(d): continue
for ext in ['.svg', '.png', '.xpm']:
target = os.path.join(d, icon_name + ext)
if os.path.exists(target):
resolved_icon_path = target
break
matches = glob.glob(os.path.join(d, f'*{icon_name}*{ext}'))
if matches:
resolved_icon_path = matches[0]
break
if resolved_icon_path:
break
if not resolved_icon_path:
resolved_icon_path = icon_name
info = {
"appId": app_lower,
"name": app_name,
"icon": resolved_icon_path
}
_icon_cache[app_lower] = info
return info
FULLSCREEN_FILE = "/tmp/quickshell_is_fullscreen.txt"
class ActiveAppService(dbus.service.Object):
def __init__(self, bus_name):
super().__init__(bus_name, "/ActiveApp")
@dbus.service.method("io.quickshell.ActiveApp", in_signature="sss")
def updateState(self, active_app, open_windows_json, is_fullscreen_str="false"):
try:
with open(ACTIVE_FILE, "w") as f:
f.write(str(active_app).strip().lower())
with open(FULLSCREEN_FILE, "w") as f:
f.write("1" if str(is_fullscreen_str).strip().lower() == "true" else "0")
raw_wins = json.loads(open_windows_json)
enriched_wins = []
for app_id in raw_wins:
enriched_wins.append(resolve_app_info(app_id))
with open(OPEN_WINS_FILE, "w") as f:
f.write(json.dumps(enriched_wins))
except Exception:
pass
return True
@dbus.service.method("io.quickshell.ActiveApp")
def toggleLauncher(self):
try:
print("TOGGLE_LAUNCHER", flush=True)
except Exception:
pass
return True
def setup_kwin_listener():
js_template = "/home/sho/Documents/themes/quickshell/services/js/kwin_state_listener.js"
js_path = "/tmp/kwin_state_listener.js"
try:
if os.path.exists(js_template):
shutil.copyfile(js_template, js_path)
# Unload previous script instance to prevent KWin memory leaks & duplicate listeners
subprocess.run(
["busctl", "--user", "call", "org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting", "unloadScript", "s", js_path],
capture_output=True
)
res = subprocess.check_output(
["busctl", "--user", "call", "org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting", "loadScript", "s", js_path],
text=True, stderr=subprocess.DEVNULL
)
sid = res.strip().split()[-1]
subprocess.check_output(
["busctl", "--user", "call", "org.kde.KWin", f"/Scripting/Script{sid}", "org.kde.kwin.Script", "run"],
text=True, stderr=subprocess.DEVNULL
)
except Exception:
pass
def main():
try:
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus_name = dbus.service.BusName("io.quickshell.ActiveApp", bus=dbus.SessionBus())
service = ActiveAppService(bus_name)
setup_kwin_listener()
loop = GLib.MainLoop()
loop.run()
except Exception:
pass
if __name__ == "__main__":
main()