From: mar77i Date: Sat, 24 Jan 2026 03:05:57 +0000 (+0100) Subject: initial commit X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=67d53c587a5f537ee99b8411255a7ebab1bfe1f0;p=localapps initial commit --- 67d53c587a5f537ee99b8411255a7ebab1bfe1f0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2483976 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +__pycache__/ diff --git a/apps/librewolf.py b/apps/librewolf.py new file mode 100644 index 0000000..a846a09 --- /dev/null +++ b/apps/librewolf.py @@ -0,0 +1,128 @@ +import hashlib +import sys +import sysconfig +from base64 import b64encode +from html.parser import HTMLParser +from shutil import rmtree +from subprocess import DEVNULL, check_output, run +from urllib.request import Request, urlopen +from xml.etree import ElementTree + +from localapps import AppBase, find_html_attr + + +class DownloadFinder(HTMLParser): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.attention = False + self.links = [] + + @classmethod + def get_classes(cls, attrs): + class_attr = find_html_attr(attrs, "class") + if class_attr is None: + return [] + elif isinstance(class_attr, list): + return [c for attr in class_attr for c in attr.split()] + return class_attr.split() + + def handle_starttag(self, tag, attrs): + tag = tag.upper() + if tag == "DETAILS" and "download" in self.get_classes(attrs): + self.attention = True + elif self.attention and tag == "A": + href = find_html_attr(attrs, "href") + if href and not "archive-link" in self.get_classes(attrs): + self.links.append(href) + + def handle_endtag(self, tag): + if self.attention and tag.upper() == "DETAILS": + self.attention = False + + +class LibreWolfApp(AppBase): + NAME = "LibreWolf" + RELEASE_URL = "https://codeberg.org/librewolf/bsys6/releases.rss" + DESKTOP_URL = "https://aur.archlinux.org/cgit/aur.git/plain/librewolf.desktop?h=librewolf" + ICON_URL = "https://aur.archlinux.org/cgit/aur.git/plain/default192x192.png?h=librewolf" + BIN_PATH = AppBase.APPS_DIR / "librewolf" / "librewolf" + + @classmethod + def get_latest_version(cls): + tree = ElementTree.parse(urlopen(cls.RELEASE_URL)) + return next(iter(tree.getroot())).find("item").find("title").text + + @classmethod + def get_installed_version(cls): + if cls.has_executable(): + version_output = check_output([str(cls.BIN_PATH), "-V"], text=True).strip() + return version_output[version_output.rfind(" ") + 1:] + return None + + @staticmethod + def find_main_url(urls): + for url in urls: + url_dot = f"{url}." + others = [other for other in urls if other != url] + if all(other.startswith(url_dot) for other in others): + return url, others + + @classmethod + def install(cls): + tree = ElementTree.parse(urlopen(cls.RELEASE_URL)) + url = next(iter(tree.getroot())).find("item").find("link").text + download_finder = DownloadFinder() + request = Request(url, headers={"Cookie": "x-robot-challenge=passed"}) + download_finder.feed(urlopen(request).read().decode()) + urls = [ + link for link in download_finder.links + if f"-{sysconfig.get_platform()}-package." in link + ] + main_url, check_urls = cls.find_main_url(urls) + content = cls.cached_download(main_url) + for check_url in check_urls: + suffix = check_url[check_url.rfind("."):] + if suffix == ".sig": + data = b64encode(urlopen(check_url).read()) + run( + [ + "bash", + "-c", + ( + "gpg --auto-key-retrieve " + f'--verify <(echo "{data.decode()}"|base64 -d) -' + ), + ], + check=True, + input=content, + stderr=DEVNULL, + ) + continue + elif not suffix.endswith("sum"): + print(f"Ignoring {check_url[check_url.rfind('/') + 1:]}", file=sys.stderr) + continue + m = getattr(hashlib, suffix[1:-3])(content) + assert m.hexdigest() == urlopen(check_url).read().decode().strip() + assert AppBase.APPS_DIR.is_dir() or not AppBase.APPS_DIR.exists() + app_dir = (AppBase.APPS_DIR / cls.NAME.lower()).absolute() + app_dir.mkdir(0o755, parents=True, exist_ok=True) + run( + ["tar", "xJ", "--strip-components=1", "-C", str(app_dir)], + check=True, + input=content + ) + desktop_file = urlopen(cls.DESKTOP_URL).read().decode() + desktop_file = desktop_file.replace("/usr/lib/librewolf", str(app_dir)).replace( + "\nIcon=librewolf\n", f"\nIcon={str(app_dir / 'default192x192.png')}\n" + ) + with (app_dir / "default192x192.png").open("wb") as fh: + fh.write(urlopen(cls.ICON_URL).read()) + with (cls.get_xdg_home() / "applications" / "librewolf.desktop").open("wt") as fh: + fh.write(desktop_file) + + @classmethod + def uninstall(cls): + rmtree((AppBase.APPS_DIR / cls.NAME.lower()).absolute()) + ( + cls.get_xdg_home() / "applications" / "librewolf.desktop" + ).unlink(missing_ok=True) diff --git a/apps/materialgram.py b/apps/materialgram.py new file mode 100644 index 0000000..c78d57b --- /dev/null +++ b/apps/materialgram.py @@ -0,0 +1,211 @@ +import hashlib +import sysconfig +from functools import reduce +from html.parser import HTMLParser +from operator import mul +from shutil import rmtree +from subprocess import run +from urllib.parse import urljoin +from urllib.request import Request, urlopen +from xml.etree import ElementTree + +from localapps import AppBase, find_html_attr + + +class FragmentFinder(HTMLParser): + def __init__(self, *args, **kwargs): + self.url = kwargs.pop("url") + super().__init__(*args, **kwargs) + self.fragments = [] + + def handle_starttag(self, tag, attrs): + if tag.upper() != "INCLUDE-FRAGMENT" or find_html_attr(attrs, "data-target"): + return + self.fragments.append( + { + "src": find_html_attr(attrs, "src"), + "nonce": find_html_attr(attrs, "data-nonce"), + } + ) + + +class DownloadFinder(HTMLParser): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.attention = False + self.spans = False + self.items = [] + + def handle_starttag(self, tag, attrs): + tag = tag.upper() + if not self.attention: + if tag == "UL": + self.attention = True + elif tag == "A": + href = find_html_attr(attrs, "href") + if href: + self.items.append( + { + "href": href, + "spans": [], + } + ) + elif tag == "SPAN": + self.spans = True + + def handle_data(self, data): + if self.spans: + data = data.strip() + if data: + self.items[-1]["spans"].append(data) + + def handle_endtag(self, tag): + if self.spans and tag.upper() == "SPAN": + self.spans = False + elif self.attention and tag.upper() == "UL": + self.attention = False + + +class Materialgram(AppBase): + NAME = "Materialgram" + RELEASE_URL = "https://github.com/kukuruzka165/materialgram/releases.atom" + BIN_PATH = AppBase.APPS_DIR / "materialgram" / "usr" / "bin" / "materialgram" + NS = {"": "http://www.w3.org/2005/Atom"} + + @classmethod + def get_latest_version(cls): + version = ( + ElementTree + .parse(urlopen(cls.RELEASE_URL)) + .getroot() + .find("entry", cls.NS) + .find("title", cls.NS) + .text + ) + if version.startswith("v"): + version = version[1:] + return version + + @classmethod + def get_installed_version(cls): + if cls.has_executable(): + with ( + cls.BIN_PATH.parents[1] + / "share" + / "metainfo" + / "io.github.kukuruzka165.materialgram.metainfo.xml" + ).open("rb") as fh: + return ( + ElementTree + .parse(fh) + .getroot() + .find("releases") + .find("release") + .attrib["version"] + ) + return None + + @classmethod + def get_desktop_file_path(cls): + desktop_file_paths = [ + path + for path in ( + (AppBase.APPS_DIR / cls.NAME.lower()).absolute() + / "usr" + / "share" + / "applications" + ).iterdir() + if path.suffix == ".desktop" + ] + assert len(desktop_file_paths) == 1 + return desktop_file_paths[0] + + @classmethod + def install(cls): + url = ( + ElementTree + .parse(urlopen(cls.RELEASE_URL)) + .getroot() + .find("entry", cls.NS) + .find("link", cls.NS) + .attrib["href"] + ) + fragment_finder = FragmentFinder(url=url) + fragment_finder.feed(urlopen(url).read().decode()) + new_url = fragment_finder.fragments[0]["src"] + download_finder = DownloadFinder() + nonce = fragment_finder.fragments[0]["nonce"] + download_finder.feed( + urlopen( + Request( + new_url, + headers={ + "X-Fetch-Nonce-To-Validate": nonce, + "X-Fetch-Nonce": nonce, + } + ), + ).read().decode() + ) + if sysconfig.get_platform() == "linux-x86_64": + item = next( + item + for item in download_finder.items + if ( + item["href"][item["href"].rfind("/"):].startswith("/materialgram-") + and item["href"].endswith(".tar.gz") + ) + ) + else: + raise NotImplementedError + content = cls.cached_download(urljoin(url, item["href"])) + algo, hexdigest = item["spans"][1].split(":") + m = getattr(hashlib, algo)(content) + assert m.hexdigest() == hexdigest + app_dir = (AppBase.APPS_DIR / cls.NAME.lower()).absolute() + app_dir.mkdir(0o755, parents=True, exist_ok=True) + run(["tar", "xz", "-C", str(app_dir)], input=content) + desktop_file_path = cls.get_desktop_file_path() + with desktop_file_path.open("rt") as fh: + desktop_file = fh.read() + desktop_file = desktop_file.replace( + "Exec=materialgram", f"Exec={app_dir}/usr/bin/materialgram" + ) + with desktop_file_path.open("wt") as fh: + fh.write(desktop_file) + stack = [(app_dir / "usr" / "share" / "icons").iterdir()] + svg_icons = [] + raster_icons = [] + while len(stack): + try: + path = next(stack[-1]) + except StopIteration: + stack.pop() + continue + if path.is_dir(): + stack.append(path.iterdir()) + elif path.suffix == ".svg": + svg_icons.append(path) + else: + raster_icons.append(path) + for icon in svg_icons: + if all(x not in path.stem.split("-") for x in ("mute", "attention")): + break + else: + icon = max( + raster_icons, + key=reduce(mul, (int(num) for num in path.parts[-3].split("x"))), + ) + desktop_file = desktop_file.replace( + f"Icon={icon.stem.replace('-symbolic', '')}", f"Icon={icon}" + ) + with ( + cls.get_xdg_home() / "applications" / desktop_file_path.name + ).open("wt") as fh: + fh.write(desktop_file) + + @classmethod + def uninstall(cls): + ( + cls.get_xdg_home() / "applications" / cls.get_desktop_file_path().name + ).unlink(missing_ok=True) + rmtree((AppBase.APPS_DIR / cls.NAME.lower()).absolute()) diff --git a/apps/pycharm.py b/apps/pycharm.py new file mode 100644 index 0000000..6c399f6 --- /dev/null +++ b/apps/pycharm.py @@ -0,0 +1,79 @@ +import hashlib +import json +import sysconfig +from collections import OrderedDict +from configparser import ConfigParser +from shutil import rmtree +from subprocess import check_output, run +from urllib.request import urlopen + +from localapps import AppBase + + +class DesktopConfigParser(ConfigParser): + def optionxform(self, optionstr): + return optionstr + + +class PyCharm(AppBase): + NAME = "PyCharm" + RELEASE_URL = ( + "https://data.services.jetbrains.com/products/releases" + "?code=PCP&latest=true&type=release" + ) + BIN_PATH = AppBase.APPS_DIR / "pycharm" / "bin" / "pycharm.sh" + + @classmethod + def get_latest_version(cls): + return json.load(urlopen(cls.RELEASE_URL))["PCP"][0]["version"] + + @classmethod + def get_installed_version(cls): + if cls.has_executable(): + for line in check_output( + [str(cls.BIN_PATH), "--version"], text=True + ).split("\n"): + if line.startswith("PyCharm"): + return line.split()[1] + return None + + @classmethod + def install(cls): + download = json.load( + urlopen(cls.RELEASE_URL) + )["PCP"][0]["downloads"][sysconfig.get_platform().split("-")[0]] + content = cls.cached_download(download["link"]) + assert len(content) == download["size"] + checksum_url = download["checksumLink"] + checksum = urlopen(checksum_url).read().decode() + pos = checksum.find(" ") + if pos > 0: + checksum = checksum[:pos] + m = getattr(hashlib, checksum_url[checksum_url.rfind(".") + 1:])(content) + assert m.hexdigest() == checksum + app_dir = (AppBase.APPS_DIR / cls.NAME.lower()).absolute() + app_dir.mkdir(0o755, parents=True, exist_ok=True) + run(["tar", "xz", "--strip-components=1", "-C", str(app_dir)], input=content) + config_parser = DesktopConfigParser(interpolation=None) + config_parser["Desktop Entry"] = OrderedDict( + [ + ("Version", "1.0"), + ("Type", "Application"), + ("Name", "PyCharm Community Edition"), + ("Comment", "Python IDE for Professional Developers"), + ("Exec", f"{cls.BIN_PATH} %f"), + ("Icon", str(cls.BIN_PATH.parent / "pycharm.svg")), + ("StartupNotify", "true"), + ("StartupWMClass", "jetbrains-pycharm-ce"), + ("Catgories", "Development;IDE;Java;"), + ] + ) + with (cls.get_xdg_home() / "applications" / "pycharm.desktop").open("wt") as fh: + config_parser.write(fh, False) + + @classmethod + def uninstall(cls): + rmtree((AppBase.APPS_DIR / cls.NAME.lower()).absolute()) + ( + cls.get_xdg_home() / "applications" / "pycharm.desktop" + ).unlink(missing_ok=True) diff --git a/localapps.py b/localapps.py new file mode 100755 index 0000000..197e0e2 --- /dev/null +++ b/localapps.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +import os +import sys +from argparse import ArgumentParser +from importlib import import_module +from pathlib import Path +from stat import S_IXGRP, S_IXOTH, S_IXUSR +from subprocess import check_output + + +class IterableHolder(type): + def __init__(self, name, bases, namespace, attr_name=None): + super().__init__(name, bases, namespace) + self.attr_name = attr_name + + def __new__(cls, *args, **kwargs): + kwargs.pop("attr_name", None) + return super().__new__(cls, *args, **kwargs) + + def __iter__(self): + return iter(getattr(self, self.attr_name)) + + +class AppBase(metaclass=IterableHolder, attr_name="registry"): + NAME: str + APPS_DIR = Path("~/local_apps").expanduser() + BIN_PATH: Path + registry: list[type[AppBase]] = [] + + def __init_subclass__(cls, *args, **kwargs): + cls.registry.append(cls) + assert cls.NAME is not None + + @classmethod + def discover(cls): + self_file = Path(__file__) + base_path = self_file.parent + stack = [base_path.iterdir()] + while stack: + try: + f = next(stack[-1]) + except StopIteration: + stack.pop() + continue + if f.is_dir(): + if f.name.startswith(".") or f.name == "__pycache__": + continue + stack.append(f.iterdir()) + elif f == self_file or not f.is_file(): + continue + if f.suffix == ".py": + import_module(str(f.relative_to(base_path))[:-3].replace("/", "."), ".") + + @classmethod + def get_latest_version(cls): + raise NotImplementedError + + @classmethod + def has_executable(cls): + return ( + cls.BIN_PATH.exists() + and cls.BIN_PATH.stat().st_mode & (S_IXUSR|S_IXGRP|S_IXOTH) != 0 + ) + + @classmethod + def get_installed_version(cls): + raise NotImplementedError + + @classmethod + def install(cls): + raise NotImplementedError + + @classmethod + def upgrade(cls): + if cls.get_installed_version() == cls.get_latest_version(): + return + cls.uninstall() + cls.install() + + @classmethod + def uninstall(cls): + raise NotImplementedError + + @staticmethod + def filename_from_url(url): + filename = url[url.rfind("/") + 1:] + pos = filename.find("?") + if pos > 0: + filename = filename[:pos] + return filename + + @classmethod + def cached_download(cls, url, filename=None): + if filename is None: + filename = cls.filename_from_url(url) + path = Path(filename) + if path.exists(): + with path.open("rb") as fh: + return fh.read() + payload = check_output(["curl", "-L", url]) + with path.open("wb") as fh: + fh.write(payload) + return payload + + @staticmethod + def get_xdg_home(): + return Path(os.environ.get("XDG_DATA_HOME") or "~/.local/share").expanduser() + + +def find_html_attr(attrs, attr_name): + attrs = [value for name, value in attrs if name == attr_name] + num = len(attrs) + if num == 0: + return None + elif num == 1: + return attrs[0] + return attrs + + +class LocalAppsManager: + def parse_args(self): + ap = ArgumentParser() + subparsers = ap.add_subparsers(dest="action") + list_cmd = subparsers.add_parser("list") + list_cmd.add_argument("--installed", action="store_true") + list_cmd.add_argument("--not-installed", action="store_false", dest="installed") + list_cmd.add_argument( + "--all", const=None, dest="installed", action="store_const" + ) + upgrade_cmd = subparsers.add_parser("upgrade") + upgrade_cmd.add_argument("--check", action="store_true") + install_cmd = subparsers.add_parser("install") + install_cmd.add_argument("app_names", nargs="+", metavar="app_name") + uninstall_cmd = subparsers.add_parser("uninstall") + uninstall_cmd.add_argument("app_names", nargs="+", metavar="app_name") + ap.set_defaults(action="list") + return ap.parse_args() + + def __init__(self): + self.args = self.parse_args() + sys.modules["localapps"] = sys.modules["__main__"] + AppBase.discover() + + def run(self): + getattr(self, self.args.action)() + + def list(self): + for app in AppBase: + latest = app.get_latest_version() + assert latest is not None + print(app.NAME, "latest", latest, end="") + installed = app.get_installed_version() + if installed is None: + print() + continue + print(" installed", installed) + + def upgrade(self): + for app in AppBase: + installed = app.get_installed_version() + if installed is None: + continue + print("upgrade stub", app.NAME) + + def get_pending_apps(self): + app_names = [app_name.upper() for app_name in self.args.app_names] + pending_apps = [app for app in AppBase if app.NAME.upper() in app_names] + if len(pending_apps) != len(app_names): + missing_apps = [ + app_name + for app_name in self.args.app_names + if app_name.upper() not in [ + pending_app.NAME.upper() for pending_app in pending_apps + ] + ] + print("Error: invalid app names:", ", ".join(missing_apps), file=sys.stderr) + exit(1) + return pending_apps + + def install(self): + for pending_app in self.get_pending_apps(): + if pending_app.get_installed_version() is None: + pending_app.install() + else: + pending_app.upgrade() + + def uninstall(self): + for pending_app in self.get_pending_apps(): + if pending_app.get_installed_version() is not None: + pending_app.uninstall() + + +if __name__ == "__main__": + LocalAppsManager().run()