227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
from pathlib import Path
|
|
import os
|
|
import configparser
|
|
from typing import Optional, List
|
|
import platform
|
|
import subprocess
|
|
import shlex
|
|
|
|
if platform.system() == "Windows":
|
|
try:
|
|
from win32com.client import Dispatch # type: ignore
|
|
import win32api # type: ignore
|
|
import win32con # type: ignore
|
|
except ImportError:
|
|
print("Windows specific functionality requires 'pywin32'. Please run 'pip install pywin32'.")
|
|
Dispatch = None
|
|
win32api = None
|
|
win32con = None
|
|
|
|
_app_cache: Optional[list['App']] = None
|
|
|
|
class App:
|
|
def __init__(self, name: str, exec: str, icon: str = "", hidden: bool = False, generic_name: str = "", comment: str = "", command: str = "", keywords: Optional[List[str]] = None):
|
|
self.name = name
|
|
self.exec = exec
|
|
self.icon = icon
|
|
self.hidden = hidden
|
|
self.generic_name = generic_name
|
|
self.comment = comment
|
|
self.command = command if command else os.path.basename(exec.split(' ')[0])
|
|
self.keywords = keywords if keywords is not None else []
|
|
|
|
def __str__(self):
|
|
return f"App(name={self.name}, exec={self.exec}, command={self.command}, icon={self.icon}, hidden={self.hidden}, generic_name={self.generic_name}, comment={self.comment}, keywords={self.keywords})"
|
|
|
|
def get_desktop_dirs_linux():
|
|
dirs = [
|
|
Path.home() / ".local/share/applications",
|
|
Path.home() / ".var/lib/app/flatpak/exports/share/applications",
|
|
Path("/usr/share/applications"),
|
|
Path("/usr/local/share/applications"),
|
|
Path("/var/lib/flatpak/exports/share/applications")
|
|
]
|
|
|
|
xdg_data_dirs = os.environ.get("XDG_DATA_DIRS", "").split(":")
|
|
for xdg_dir in xdg_data_dirs:
|
|
if xdg_dir:
|
|
dirs.append(Path(xdg_dir) / "applications")
|
|
|
|
return [d for d in dirs if d.exists()]
|
|
|
|
def get_start_menu_dirs_windows():
|
|
appdata = os.getenv('APPDATA')
|
|
programdata = os.getenv('PROGRAMDATA')
|
|
dirs = []
|
|
if appdata:
|
|
dirs.append(Path(appdata) / "Microsoft/Windows/Start Menu/Programs")
|
|
if programdata:
|
|
dirs.append(Path(programdata) / "Microsoft/Windows/Start Menu/Programs")
|
|
return [d for d in dirs if d.exists()]
|
|
|
|
def parse_desktop_file(file_path: Path) -> list[App]:
|
|
apps = []
|
|
config = configparser.ConfigParser(interpolation=None)
|
|
|
|
try:
|
|
config.read(file_path, encoding='utf-8')
|
|
except Exception:
|
|
return []
|
|
|
|
if 'Desktop Entry' not in config:
|
|
return []
|
|
|
|
main_entry = config['Desktop Entry']
|
|
main_name = main_entry.get('Name')
|
|
|
|
is_hidden = main_entry.get('Hidden', 'false').lower() == 'true' or \
|
|
main_entry.get('NoDisplay', 'false').lower() == 'true'
|
|
|
|
if main_name and not is_hidden:
|
|
main_exec = main_entry.get('Exec')
|
|
keywords_str = main_entry.get('Keywords', '')
|
|
keywords = [k.strip() for k in keywords_str.split(';') if k.strip()]
|
|
|
|
if main_exec:
|
|
apps.append(App(
|
|
name=main_name,
|
|
exec=main_exec,
|
|
icon=main_entry.get('Icon', ''),
|
|
hidden=False,
|
|
generic_name=main_entry.get('GenericName', ''),
|
|
comment=main_entry.get('Comment', ''),
|
|
keywords=keywords
|
|
))
|
|
|
|
if 'Actions' in main_entry:
|
|
action_ids = [action for action in main_entry['Actions'].split(';') if action]
|
|
for action_id in action_ids:
|
|
action_section_name = f'Desktop Action {action_id}'
|
|
if action_section_name in config:
|
|
action_section = config[action_section_name]
|
|
action_name = action_section.get('Name')
|
|
action_exec = action_section.get('Exec')
|
|
|
|
if action_name and action_exec:
|
|
combined_name = f"{main_name} - {action_name}"
|
|
apps.append(App(
|
|
name=combined_name,
|
|
exec=action_exec,
|
|
icon=main_entry.get('Icon', ''),
|
|
keywords=keywords
|
|
))
|
|
return apps
|
|
|
|
def parse_lnk_file(file_path: Path) -> Optional[App]:
|
|
if not Dispatch:
|
|
return None
|
|
try:
|
|
shell = Dispatch("WScript.Shell")
|
|
shortcut = shell.CreateShortCut(str(file_path))
|
|
|
|
target = shortcut.TargetPath
|
|
arguments = shortcut.Arguments
|
|
|
|
if not target or not os.path.exists(target):
|
|
return None
|
|
|
|
full_exec = f'"{target}"'
|
|
if arguments:
|
|
full_exec += f' {arguments}'
|
|
|
|
return App(
|
|
name=file_path.stem,
|
|
exec=full_exec,
|
|
comment=shortcut.Description,
|
|
icon=shortcut.IconLocation.split(',')[0] if shortcut.IconLocation else ""
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
def is_user_dir(path: Path) -> bool:
|
|
path_str = str(path)
|
|
user_home = str(Path.home())
|
|
return path_str.startswith(user_home)
|
|
|
|
def list_apps_linux() -> List[App]:
|
|
apps_dict = {}
|
|
for desktop_dir in get_desktop_dirs_linux():
|
|
is_user = is_user_dir(desktop_dir)
|
|
for file_path in desktop_dir.glob("*.desktop"):
|
|
for app in parse_desktop_file(file_path):
|
|
if app.hidden or not app.name or not app.exec:
|
|
continue
|
|
|
|
if app.name in apps_dict:
|
|
existing_is_user = apps_dict[app.name][1]
|
|
if is_user and not existing_is_user:
|
|
apps_dict[app.name] = (app, is_user)
|
|
else:
|
|
apps_dict[app.name] = (app, is_user)
|
|
return [app for app, _ in apps_dict.values()]
|
|
|
|
def list_apps_windows() -> List[App]:
|
|
apps_dict = {}
|
|
for start_menu_dir in get_start_menu_dirs_windows():
|
|
for file_path in start_menu_dir.rglob("*.lnk"):
|
|
app = parse_lnk_file(file_path)
|
|
if app and app.exec and app.name:
|
|
if app.exec not in apps_dict or len(app.name) > len(apps_dict[app.exec].name):
|
|
apps_dict[app.exec] = app
|
|
return list(apps_dict.values())
|
|
|
|
def list_apps(force_reload: bool = False) -> list[App]:
|
|
global _app_cache
|
|
|
|
if _app_cache is not None and not force_reload:
|
|
return _app_cache
|
|
|
|
if platform.system() == "Windows":
|
|
_app_cache = list_apps_windows()
|
|
else:
|
|
_app_cache = list_apps_linux()
|
|
|
|
return _app_cache
|
|
|
|
def reload_app_cache() -> list[App]:
|
|
return list_apps(force_reload=True)
|
|
|
|
def launch(app: App):
|
|
if platform.system() == "Windows":
|
|
if not win32api or not win32con:
|
|
print(f"Failed to launch '{app.name}': pywin32 components are missing.")
|
|
return
|
|
|
|
try:
|
|
# Using ShellExecute is more robust for launching various application types on Windows,
|
|
# as it leverages the Windows shell's own mechanisms. This is particularly helpful for
|
|
# non-standard executables like PWAs or Microsoft Store apps.
|
|
command_parts = shlex.split(app.exec, posix=False)
|
|
target = command_parts[0]
|
|
|
|
# Use subprocess.list2cmdline to correctly re-assemble the arguments string,
|
|
# preserving quotes around arguments with spaces.
|
|
arguments = subprocess.list2cmdline(command_parts[1:])
|
|
|
|
win32api.ShellExecute(
|
|
0, # Parent window handle (0 for desktop)
|
|
"open", # Operation
|
|
target, # File to execute or open
|
|
arguments, # Parameters
|
|
"", # Working directory (None for default)
|
|
win32con.SW_SHOWNORMAL # How to show the window
|
|
)
|
|
except Exception as e:
|
|
print(f"Failed to launch '{app.name}': {e}")
|
|
else:
|
|
cleaned_exec = app.exec.split(' %')[0]
|
|
try:
|
|
subprocess.Popen(shlex.split(cleaned_exec))
|
|
except Exception as e:
|
|
print(f"Failed to launch '{app.name}': {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
apps = list_apps()
|
|
for app in sorted(apps, key=lambda a: a.name):
|
|
print(app) |