Skip to content

Instantly share code, notes, and snippets.

@timsavage
Last active November 17, 2025 02:30
Show Gist options
  • Select an option

  • Save timsavage/05211070895c3d46c3783b9ff04c6fa1 to your computer and use it in GitHub Desktop.

Select an option

Save timsavage/05211070895c3d46c3783b9ff04c6fa1 to your computer and use it in GitHub Desktop.
Script to manage AppImages downloaded to Linux running Gnome Desktop
#!/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