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

248 lines
8.3 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import json
import configparser
import sys
import time
def get_system_theme():
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:
return t
except Exception:
pass
gtk_cfg = os.path.expanduser('~/.config/gtk-3.0/settings.ini')
if os.path.exists(gtk_cfg):
cp = configparser.ConfigParser(interpolation=None)
try:
cp.read(gtk_cfg)
t = cp.get('Settings', 'gtk-icon-theme-name', fallback=None)
if t:
return t
except Exception:
pass
return 'hicolor'
def parse_desktop_apps():
sys_theme = get_system_theme()
themes_to_search = [sys_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(sys_theme)
for fallback_theme in ['hicolor', 'breeze', 'Papirus', 'Adwaita']:
if fallback_theme not in themes_to_search:
themes_to_search.append(fallback_theme)
search_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 search_dirs:
search_dirs.append(d)
search_dirs.append('/usr/share/pixmaps')
EXTS = ['.svg', '.png', '.xpm']
def resolve_fallback():
for fallback_name in ['application-x-executable', 'preferences-system-windows', 'applications-other', 'utilities-terminal']:
for d in search_dirs:
for ext in EXTS:
p = os.path.join(d, fallback_name + ext)
if os.path.exists(p):
return p
return ''
fallback_path = resolve_fallback()
def resolve_icon(name):
if not name:
return fallback_path
if os.path.isabs(name) and os.path.exists(name):
return name
for d in search_dirs:
for ext in EXTS:
p = os.path.join(d, name + ext)
if os.path.exists(p):
return p
if name.endswith(ext):
p2 = os.path.join(d, name)
if os.path.exists(p2):
return p2
return fallback_path
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
desktop_dirs = [
os.path.expanduser('~/.local/share/applications'),
os.path.expanduser('~/.local/share/flatpak/exports/share/applications'),
'/usr/local/share/applications',
'/usr/share/applications',
'/var/lib/flatpak/exports/share/applications'
]
import re
def normalize_exec(cmd):
if not cmd:
return ""
return re.sub(r'%[a-zA-Z]', '', cmd).strip()
seen_ids = set()
seen_apps = set()
apps = []
for d in desktop_dirs:
if not os.path.exists(d):
continue
try:
for entry in sorted(os.scandir(d), key=lambda e: e.name):
if not entry.name.endswith('.desktop'):
continue
desktop_id = entry.name
if desktop_id in seen_ids:
continue
s = parse_desktop(entry.path)
if s.get('Type', '') != 'Application':
continue
if s.get('NoDisplay', 'false').strip().lower() == 'true':
continue
if s.get('Hidden', 'false').strip().lower() == 'true':
continue
if s.get('Terminal', 'false').strip().lower() in ['true', '1']:
continue
only_in = [x.strip().lower() for x in s.get('OnlyShowIn', '').split(';') if x.strip()]
not_in = [x.strip().lower() for x in s.get('NotShowIn', '').split(';') if x.strip()]
if only_in and 'kde' not in only_in:
continue
if not_in and 'kde' in not_in:
continue
name = s.get('Name', '')
if not name:
continue
exec_cmd = s.get('Exec', '')
norm_exec = normalize_exec(exec_cmd)
app_key = (name.strip().lower(), norm_exec.lower())
if app_key in seen_apps:
continue
seen_ids.add(desktop_id)
seen_apps.add(app_key)
cats = [c for c in s.get('Categories', '').split(';') if c.strip()]
icon = resolve_icon(s.get('Icon', ''))
apps.append({
'id': desktop_id,
'name': name,
'exec': exec_cmd,
'icon': icon,
'categories': cats
})
except Exception:
pass
apps.sort(key=lambda a: a['name'].lower())
print(json.dumps({'fallback': fallback_path, 'apps': apps}), flush=True)
def watch_directory_changes():
import select
dirs = [
'/usr/share/applications',
'/usr/local/share/applications',
'/var/lib/flatpak/exports/share/applications',
os.path.expanduser('~/.local/share/applications'),
os.path.expanduser('~/.local/share/flatpak/exports/share/applications')
]
def get_state_snapshot():
snapshot = {}
for d in dirs:
if os.path.exists(d):
try:
snapshot[d] = os.path.stat(d).st_mtime
for entry in os.scandir(d):
if entry.name.endswith('.desktop'):
try:
snapshot[entry.path] = entry.stat().st_mtime
except Exception:
pass
except Exception:
pass
return snapshot
last_snapshot = get_state_snapshot()
parse_desktop_apps()
while True:
try:
# Check for non-blocking stdin input (reload requests from QML)
rlist, _, _ = select.select([sys.stdin], [], [], 0.5)
if rlist:
line = sys.stdin.readline()
if line:
parse_desktop_apps()
last_snapshot = get_state_snapshot()
continue
current_snapshot = get_state_snapshot()
if current_snapshot != last_snapshot:
last_snapshot = current_snapshot
parse_desktop_apps()
except Exception:
time.sleep(0.5)
if __name__ == '__main__':
watch_directory_changes()