#!/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()