Last active
November 17, 2025 02:30
-
-
Save timsavage/05211070895c3d46c3783b9ff04c6fa1 to your computer and use it in GitHub Desktop.
Script to manage AppImages downloaded to Linux running Gnome Desktop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/python3.12 | |
| """ | |
| Automatically manage AppImages downloaded to your machine. | |
| Place this script and all of your AppImages in a folder of your choice | |
| (under your home folder) eg `~/Applications/App-Images`. | |
| Run this script, and it will iterate through all the App images found and | |
| the latest version of each application: | |
| * Create Gnome Desktop entry | |
| * Add a symlink in `~/.local/bin` (ensure this is in your PATH variable) | |
| If you have an icon in `~/.local/share/icons/hicolor` that matches the app's | |
| name (with `.png` file extension), then that will be used in the Desktop entry. | |
| Post-run Gnome Desktop entries should be immediately available. | |
| """ | |
| import argparse | |
| import logging | |
| import re | |
| from collections import defaultdict | |
| from configparser import ConfigParser | |
| from pathlib import Path | |
| from typing import NamedTuple | |
| from packaging.version import Version | |
| HERE = Path(__file__).parent | |
| BIN_FOLDER = Path("~/.local/bin/").expanduser() | |
| APP_FOLDER = Path("~/.local/share/applications/").expanduser() | |
| ICON_FOLDER = Path("~/.local/share/icons/hicolor").expanduser() | |
| VERSION_RE = re.compile(r"^\d+\.\d+(\.\d+)?(\.\d+)?$") | |
| class AppEntry(NamedTuple): | |
| exe_path: Path | |
| name: str | |
| version: Version | |
| os_: str | None | |
| arch: str | None | |
| def __str__(self): | |
| return self.name | |
| def __repr__(self): | |
| return f"{self.name} {self.version} {self.os_.title()} {self.arch}" | |
| apps: dict[str, list[AppEntry]] = defaultdict(list) | |
| def parse_file_name(file_name: Path) -> AppEntry | None: | |
| """Parse a file name and return a tuple of (app_name, version, os_, arch).""" | |
| low_idx = 0 | |
| arch = None | |
| os_ = "not-specified" | |
| version = None | |
| atoms = file_name.stem.split("-") | |
| for idx, atom in enumerate(atoms): | |
| lower_atom = atom.lower() | |
| if lower_atom in ("x86_64", "x64", "x86"): | |
| arch = lower_atom | |
| if low_idx == 0: | |
| low_idx = idx | |
| elif lower_atom in ("linux",): | |
| os_ = lower_atom | |
| if low_idx == 0: | |
| low_idx = idx | |
| elif VERSION_RE.match(lower_atom): | |
| version = Version(lower_atom) | |
| if low_idx == 0: | |
| low_idx = idx | |
| if low_idx == 0: | |
| return None | |
| return AppEntry(file_name, "-".join(atoms[:low_idx]), version, os_, arch) | |
| def parse_folder(folder): | |
| """Find all AppImage files identify name and version information.""" | |
| for app_image in folder.glob("*.AppImage"): | |
| if not app_image.is_symlink(): | |
| entry = parse_file_name(app_image) | |
| if entry is None: | |
| logging.warning("Non-Spec name: %s", app_image) | |
| logging.info("Found entry: %s %s", app_image, entry[1]) | |
| apps[entry.name].append(entry) | |
| else: | |
| logging.info("SymLink: %s", app_image) | |
| def iter_latest(): | |
| """Sort applications and return the latest version.""" | |
| for entries in apps.values(): | |
| if len(entries) > 1: | |
| entry, *_ = sorted(entries, key=lambda e: e.version, reverse=True) | |
| else: | |
| (entry,) = entries | |
| yield entry | |
| def make_symlink(entry: AppEntry, *, bin_path: Path = BIN_FOLDER) -> Path: | |
| """Symlink the latest version.""" | |
| target_path = entry.exe_path.absolute() | |
| link_path = bin_path / f"{entry}.AppImage" | |
| if link_path.is_symlink(): | |
| if link_path.readlink() == target_path: | |
| logging.info("%s -Symlink exists and matches", entry) | |
| return link_path | |
| else: | |
| logging.info("%s - Symlink out of date", entry) | |
| link_path.unlink() | |
| elif link_path.exists(follow_symlinks=False): | |
| logging.info("%s - Non-Symlink already exists, skipping", entry) | |
| return link_path | |
| logging.info("%s - Creating symlink to %s", entry, target_path) | |
| link_path.symlink_to(target_path) | |
| link_path.chmod(0o755) | |
| return link_path | |
| def find_icon(entry: AppEntry, *, icon_path: Path = ICON_FOLDER) -> Path | None: | |
| """Find matching icon.""" | |
| for parent in icon_path.iterdir(): | |
| app_icon = parent / f"apps/{entry}.png" | |
| if app_icon.is_file(): | |
| return app_icon | |
| return None | |
| def read_desktop_entry(desktop_path: Path): | |
| """Read a Gnome desktop entry.""" | |
| config = ConfigParser() | |
| config.optionxform = str | |
| config.read(desktop_path) | |
| if "Desktop Entry" in config.sections(): | |
| return config["Desktop Entry"] | |
| return {} | |
| def write_desktop_entry(desktop_path: Path, entry): | |
| """Write a Gnome desktop entry.""" | |
| config = ConfigParser() | |
| config.optionxform = str | |
| config["Desktop Entry"] = entry | |
| with desktop_path.open("w") as f_out: | |
| config.write(f_out) | |
| def make_desktop_entry(entry: AppEntry, link_path: Path, *, app_path: Path = APP_FOLDER): | |
| """Create the Gnome desktop entry""" | |
| desktop_path = app_path / f"{entry.name}.desktop" | |
| if desktop_path.exists(): | |
| logging.info("%s - Desktop entry already exists", entry) | |
| desktop_entry = read_desktop_entry(desktop_path) | |
| else: | |
| desktop_entry = {"Name": name, "Terminal": False, "Type": "Application"} | |
| desktop_entry["TryExec"] = str(link_path) | |
| if "Exec" in entry: | |
| if not desktop_entry["Exec"].startswith(str(link_path)): | |
| logging.info("%s - Update Exec entry", entry) | |
| args: list[str] = desktop_entry["Exec"].split(" ") | |
| # Update the first item and don't change any args | |
| args[0] = str(link_path) | |
| desktop_entry["Exec"] = " ".join(args) | |
| else: | |
| desktop_entry["Exec"] = str(link_path) | |
| if icon_path := find_icon(entry): | |
| logging.info("%s - Found icon %s", entry, icon_path) | |
| desktop_entry["Icon"] = str(icon_path) | |
| logging.info("%s - Write desktop entry", entry) | |
| write_desktop_entry(desktop_path, desktop_entry) | |
| def get_cli(): | |
| """Get any CLI supplied options""" | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--dry-run", action="store_true") | |
| parser.add_argument( | |
| "--log-level", | |
| choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], | |
| default="INFO", | |
| ) | |
| return parser.parse_args() | |
| def main(): | |
| opts = get_cli() | |
| log_level = logging.getLevelName(opts.log_level) | |
| logging.basicConfig(level=log_level, format="%(levelname)s - %(message)s") | |
| parse_folder(HERE) | |
| for entry in iter_latest(): | |
| if opts.dry_run: | |
| print(repr(entry)) | |
| else: | |
| link_path = make_symlink(entry) | |
| make_desktop_entry(entry, link_path) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment