--- /dev/null
+.idea/
+__pycache__/
--- /dev/null
+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)
--- /dev/null
+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())
--- /dev/null
+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)
--- /dev/null
+#!/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()