]> git.mar77i.info Git - localapps/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Sat, 24 Jan 2026 03:05:57 +0000 (04:05 +0100)
committermar77i <mar77i@protonmail.ch>
Sat, 24 Jan 2026 03:05:57 +0000 (04:05 +0100)
.gitignore [new file with mode: 0644]
apps/librewolf.py [new file with mode: 0644]
apps/materialgram.py [new file with mode: 0644]
apps/pycharm.py [new file with mode: 0644]
localapps.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..2483976
--- /dev/null
@@ -0,0 +1,2 @@
+.idea/
+__pycache__/
diff --git a/apps/librewolf.py b/apps/librewolf.py
new file mode 100644 (file)
index 0000000..a846a09
--- /dev/null
@@ -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 (file)
index 0000000..c78d57b
--- /dev/null
@@ -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 (file)
index 0000000..6c399f6
--- /dev/null
@@ -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 (executable)
index 0000000..197e0e2
--- /dev/null
@@ -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()