From f276cea2a8655eddbc0f17eeb3df135737aa6d37 Mon Sep 17 00:00:00 2001 From: Dominik Heidler Date: Tue, 7 Nov 2023 15:49:16 +0100 Subject: [PATCH 1/2] Allow installing non-rpm applications (add OrcaSlicer) Allow downloading whatever is provided and build the rpm locally. --- opi/github.py | 32 +++++++++++++++ opi/http.py | 25 ++++++++++++ opi/plugins/maptool.py | 27 +----------- opi/plugins/orca_slicer.py | 53 ++++++++++++++++++++++++ opi/rpmbuild.py | 84 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 opi/github.py create mode 100644 opi/http.py create mode 100644 opi/plugins/orca_slicer.py create mode 100644 opi/rpmbuild.py diff --git a/opi/github.py b/opi/github.py new file mode 100644 index 0000000..7a4b591 --- /dev/null +++ b/opi/github.py @@ -0,0 +1,32 @@ +import requests +import opi + +def http_get_json(url): + r = requests.get(url) + r.raise_for_status() + return r.json() + +def get_releases(org, repo, filter_prereleases=True): + releases = http_get_json(f'https://api.github.com/repos/{org}/{repo}/releases') + if filter_prereleases: + releases = [release for release in releases if not release['prerelease']] + return releases + +def get_release_assets(release): + return [{'name': a['name'], 'url': a['browser_download_url']} for a in http_get_json(release['assets_url'])] + +def install_rpm_release(org, repo, allow_unsigned=False): + releases = get_releases(org, repo) + if not releases: + print(f'No release found for {org}/{repo}') + return + latest_release = releases[0] + if not opi.ask_yes_or_no(f"Do you want to install {repo} release {latest_release['tag_name']} RPM from {org} github repo?"): + return + assets = get_release_assets(latest_release) + assets = [a for a in assets if a['name'].endswith('.rpm')] + if not assets: + print(f"No RPM asset found for {org}/{repo} release {latest_release['tag_name']}") + return + rpm_url = assets[0]['url'] + opi.install_packages([rpm_url], allow_unsigned=allow_unsigned) diff --git a/opi/http.py b/opi/http.py new file mode 100644 index 0000000..c086709 --- /dev/null +++ b/opi/http.py @@ -0,0 +1,25 @@ +import os +import requests + +def download_file(url, local_filename): + response = requests.get(url, stream=True) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + block_size = 1024*512 + + os.makedirs(os.path.dirname(local_filename), exist_ok=True) + + print(f"Downloading to {local_filename}:") + with open(local_filename, 'wb') as local_file: + total_bytes_received = 0 + for data in response.iter_content(chunk_size=block_size): + local_file.write(data) + total_bytes_received += len(data) + print_progress(total_bytes_received, total_size) + print() + +def print_progress(bytes_received, total_size): + progress = (bytes_received / total_size) * 100 + progress = min(100, progress) + print(f"Progress: [{int(progress)}%] [{'=' * int(progress / 2)}{' ' * (50 - int(progress / 2))}]", end='\r') diff --git a/opi/plugins/maptool.py b/opi/plugins/maptool.py index d39bbf2..62fe2af 100644 --- a/opi/plugins/maptool.py +++ b/opi/plugins/maptool.py @@ -1,29 +1,6 @@ import opi from opi.plugins import BasePlugin - -import requests - -def http_get_json(url): - r = requests.get(url) - r.raise_for_status() - return r.json() - -def install_github_release(org, repo): - releases = http_get_json(f'https://api.github.com/repos/{org}/{repo}/releases') - releases = [release for release in releases if not release['prerelease']] - if not releases: - print(f'No release found for {org}/{repo}') - return - latest_release = releases[0] - if not opi.ask_yes_or_no(f"Do you want to install {repo} release {latest_release['tag_name']} RPM from {org} github repo?"): - return - assets = http_get_json(latest_release['assets_url']) - rpm_assets = [asset for asset in assets if asset['name'].endswith('.rpm')] - if not rpm_assets: - print(f"No RPM asset found for {org}/{repo} release {latest_release['tag_name']}") - return - rpm_url = rpm_assets[0]['browser_download_url'] - opi.install_packages([rpm_url], allow_unsigned=True) # no key available +from opi import github class MapTool(BasePlugin): main_query = 'maptool' @@ -32,4 +9,4 @@ class MapTool(BasePlugin): @classmethod def run(cls, query): - install_github_release('RPTools', 'maptool') + github.install_rpm_release('RPTools', 'maptool', allow_unsigned=True) # no key available diff --git a/opi/plugins/orca_slicer.py b/opi/plugins/orca_slicer.py new file mode 100644 index 0000000..27628f5 --- /dev/null +++ b/opi/plugins/orca_slicer.py @@ -0,0 +1,53 @@ +import os +import opi +from opi.plugins import BasePlugin +from opi import github +from opi import rpmbuild +from opi import http + +class OrcaSlicer(BasePlugin): + main_query = 'orcaslicer' + description = 'Slicer and controller for Bambu and other 3D printers' + queries = [main_query, 'orca-slicer', 'OrcaSlicer'] + + @classmethod + def run(cls, query): + org = 'SoftFever' + repo = 'OrcaSlicer' + releases = github.get_releases(org, repo) + releases = [r for r in releases if not 'beta' in r['tag_name']] + if not releases: + print(f'No release found for {org}/{repo}') + return + latest_release = releases[0] + if not opi.ask_yes_or_no(f"Do you want to install {repo} release {latest_release['tag_name']} from {org} github repo?"): + return + version = latest_release['tag_name'].lstrip('v') + assets = github.get_release_assets(latest_release) + assets = [a for a in assets if a['name'].endswith('.AppImage')] + if not assets: + print(f"No asset found for {org}/{repo} release {latest_release['tag_name']}") + return + url = assets[0]['url'] + + binary_path = 'usr/bin/OrcaSlicer' + icon_path = 'usr/share/pixmaps/OrcaSlicer.svg' + + rpm = rpmbuild.RPMBuild('OrcaSlicer', version, cls.description, "x86_64", [ + f"/{binary_path}", + f"/{icon_path}" + ]) + + binary_abspath = os.path.join(rpm.src_root_dir, binary_path) + http.download_file(url, binary_abspath) + os.chmod(binary_abspath, 0o755) + + icon_abspath = os.path.join(rpm.src_root_dir, icon_path) + icon_url = "https://raw.githubusercontent.com/SoftFever/OrcaSlicer/2d849f/resources/images/OrcaSlicer.svg" + http.download_file(icon_url, icon_abspath) + + rpm.add_desktop_file(cmd="OrcaSlicer", icon=f"/{icon_path}") + + rpm.build() + + opi.install_packages([rpm.rpmfile_path], allow_unsigned=True) diff --git a/opi/rpmbuild.py b/opi/rpmbuild.py new file mode 100644 index 0000000..cb50ca3 --- /dev/null +++ b/opi/rpmbuild.py @@ -0,0 +1,84 @@ +import os +import tempfile +import textwrap +import re +import subprocess +import glob + +class RPMBuild: + def __init__(self, name, version, description, buildarch="noarch", files=[], dirs=[], config=[]): + self.name = name + self.version = version + self.description = description + self.buildarch = buildarch + self.files = files + self.dirs = dirs + self.config = config + + self.tmpdir = tempfile.TemporaryDirectory() + + self.src_root_dir = os.path.join(self.tmpdir.name, "root") + self.spec_path = os.path.join(self.tmpdir.name, "specfile.spec") + self.tmp_buildroot_dir = os.path.join(self.tmpdir.name, "buildroot") + self.rpm_out_dir = os.path.join(self.tmpdir.name, "rpms") + + os.mkdir(self.src_root_dir) + os.mkdir(self.rpm_out_dir) + + @staticmethod + def _mkspec(name, version, description, buildarch="noarch", files=[], dirs=[], config=[]): + nl = "\n" + spec = re.sub(r"^\s*", "", f""" + Name: {name} + Version: {version} + Release: 0 + Summary: {description} + License: n/a + BuildArch: {buildarch} + + %description + {description} + Built locally using OPI. + + %install + cp -lav ./root/* %{{buildroot}}/ + + %files + {nl.join(files)} + {nl.join(f"%dir {d}" for d in dirs)} + {nl.join(f"%config {c}" for c in config)} + + %changelog + """, flags=re.M) + return spec + + def add_desktop_file(self, cmd, icon): + os.makedirs(os.path.join(self.src_root_dir, 'usr/share/applications')) + desktop_path = f'usr/share/applications/{self.name}.desktop' + desktop_abspath = os.path.join(self.src_root_dir, desktop_path) + self.files.append(f"/{desktop_path}") + with open(desktop_abspath, 'w') as f: + f.write(textwrap.dedent(f""" + [Desktop Entry] + Name={self.name} + Comment={self.description} + Exec={cmd} + Icon={icon} + Type=Application + """)) + + def build(self): + print(f"Creating RPM for {self.name}") + with open(self.spec_path, 'w') as f: + spec = type(self)._mkspec(self.name, self.version, self.description, self.buildarch, + self.files, self.dirs, self.config) + f.write(spec) + subprocess.check_call([ + "rpmbuild", "-bb", "--build-in-place", + "--buildroot", self.tmp_buildroot_dir, + "--define", f"_rpmdir {self.rpm_out_dir}", + "specfile.spec" + ], cwd=self.tmpdir.name) + rpmfile = glob.glob(f"{self.rpm_out_dir}/*/*.rpm")[0] + self.rpmfile_path = rpmfile + return rpmfile From 6a86965faed9b2aa0ef6e4dc0b021f0a8f1b848e Mon Sep 17 00:00:00 2001 From: Dominik Heidler Date: Fri, 10 Nov 2023 10:52:11 +0100 Subject: [PATCH 2/2] Add Snap library and Spotify plugin --- opi/github.py | 27 ++++++++---- opi/plugins/orca_slicer.py | 25 +++++------ opi/plugins/spotify.py | 70 +++++++++++++++++++++++++++++++ opi/rpmbuild.py | 86 +++++++++++++++++++++++++++----------- opi/snap.py | 20 +++++++++ 5 files changed, 180 insertions(+), 48 deletions(-) create mode 100644 opi/plugins/spotify.py create mode 100644 opi/snap.py diff --git a/opi/github.py b/opi/github.py index 7a4b591..65817b4 100644 --- a/opi/github.py +++ b/opi/github.py @@ -6,27 +6,36 @@ def http_get_json(url): r.raise_for_status() return r.json() -def get_releases(org, repo, filter_prereleases=True): +def get_releases(org, repo, filter_prereleases=True, filters=[]): releases = http_get_json(f'https://api.github.com/repos/{org}/{repo}/releases') if filter_prereleases: releases = [release for release in releases if not release['prerelease']] + for f in filters: + releases = [r for r in releases if f(r)] return releases +def get_latest_release(org, repo, filter_prereleases=True, filters=[]): + releases = get_releases(org, repo, filter_prereleases, filters) + return releases[0] if releases else None + def get_release_assets(release): return [{'name': a['name'], 'url': a['browser_download_url']} for a in http_get_json(release['assets_url'])] +def get_release_asset(release, filters=[]): + assets = get_release_assets(release) + for f in filters: + assets = [r for r in assets if f(r)] + return assets[0] if assets else None + def install_rpm_release(org, repo, allow_unsigned=False): - releases = get_releases(org, repo) - if not releases: + latest_release = get_latest_release(org, repo) + if not latest_release: print(f'No release found for {org}/{repo}') return - latest_release = releases[0] if not opi.ask_yes_or_no(f"Do you want to install {repo} release {latest_release['tag_name']} RPM from {org} github repo?"): return - assets = get_release_assets(latest_release) - assets = [a for a in assets if a['name'].endswith('.rpm')] - if not assets: + asset = get_release_asset(latest_release, filters=[lambda a: a['name'].endswith('.rpm')]) + if not asset: print(f"No RPM asset found for {org}/{repo} release {latest_release['tag_name']}") return - rpm_url = assets[0]['url'] - opi.install_packages([rpm_url], allow_unsigned=allow_unsigned) + opi.install_packages([asset['url']], allow_unsigned=allow_unsigned) diff --git a/opi/plugins/orca_slicer.py b/opi/plugins/orca_slicer.py index 27628f5..cd989c9 100644 --- a/opi/plugins/orca_slicer.py +++ b/opi/plugins/orca_slicer.py @@ -14,35 +14,32 @@ class OrcaSlicer(BasePlugin): def run(cls, query): org = 'SoftFever' repo = 'OrcaSlicer' - releases = github.get_releases(org, repo) - releases = [r for r in releases if not 'beta' in r['tag_name']] - if not releases: + latest_release = github.get_latest_release(org, repo) + if not latest_release: print(f'No release found for {org}/{repo}') return - latest_release = releases[0] - if not opi.ask_yes_or_no(f"Do you want to install {repo} release {latest_release['tag_name']} from {org} github repo?"): + version = latest_release['tag_name'].lstrip('v').replace('-', '_') + if not opi.ask_yes_or_no(f"Do you want to install {repo} release {version} from {org} github repo?"): return - version = latest_release['tag_name'].lstrip('v') - assets = github.get_release_assets(latest_release) - assets = [a for a in assets if a['name'].endswith('.AppImage')] - if not assets: - print(f"No asset found for {org}/{repo} release {latest_release['tag_name']}") + asset = github.get_release_asset(latest_release, filters=[lambda a: a['name'].endswith('.AppImage')]) + if not asset: + print(f"No asset found for {org}/{repo} release {version}") return - url = assets[0]['url'] + url = asset['url'] binary_path = 'usr/bin/OrcaSlicer' icon_path = 'usr/share/pixmaps/OrcaSlicer.svg' - rpm = rpmbuild.RPMBuild('OrcaSlicer', version, cls.description, "x86_64", [ + rpm = rpmbuild.RPMBuild('OrcaSlicer', version, cls.description, "x86_64", files=[ f"/{binary_path}", f"/{icon_path}" ]) - binary_abspath = os.path.join(rpm.src_root_dir, binary_path) + binary_abspath = os.path.join(rpm.buildroot, binary_path) http.download_file(url, binary_abspath) os.chmod(binary_abspath, 0o755) - icon_abspath = os.path.join(rpm.src_root_dir, icon_path) + icon_abspath = os.path.join(rpm.buildroot, icon_path) icon_url = "https://raw.githubusercontent.com/SoftFever/OrcaSlicer/2d849f/resources/images/OrcaSlicer.svg" http.download_file(icon_url, icon_abspath) diff --git a/opi/plugins/spotify.py b/opi/plugins/spotify.py new file mode 100644 index 0000000..0ee527b --- /dev/null +++ b/opi/plugins/spotify.py @@ -0,0 +1,70 @@ +import os +import opi +import shutil +from opi.plugins import BasePlugin +from opi import rpmbuild +from opi import snap +from opi import http + +class Spotify(BasePlugin): + main_query = 'spotify' + description = 'Listen to music for a monthly fee' + queries = [main_query] + + @classmethod + def run(cls, query): + s = snap.get_snap('spotify') + if not opi.ask_yes_or_no(f"Do you want to install spotify release {s['version']} converted to RPM from snapcraft repo?"): + return + + binary_path = 'usr/bin/spotify' + data_path = 'usr/share/spotify' + + rpm = rpmbuild.RPMBuild('spotify', s['version'], cls.description, "x86_64", + conflicts = ["spotify-client"], + requires = [ + "libasound2", + "libatk-bridge-2_0-0", + "libatomic1", + "libcurl4", + "libgbm1", + "libglib-2_0-0", + "libgtk-3-0", + "mozilla-nss", + "libopenssl1_1", + "libxshmfence1", + "libXss1", + "libXtst6", + "xdg-utils", + "libayatana-appindicator3-1", + ], + autoreq = False, + recommends = [ + "libavcodec.so", + "libavformat.so", + ], + suggests = ["libnotify4"], + files = [ + f"/{binary_path}", + f"/{data_path}" + ] + ) + + snap_path = os.path.join(rpm.tmpdir.name, 'spotify.snap') + snap_path_extracted = os.path.join(rpm.tmpdir.name, 'spotify') + http.download_file(s['url'], snap_path) + snap.extract_snap(snap_path, snap_path_extracted) + + binary_abspath = os.path.join(rpm.buildroot, binary_path) + rpmbuild.copy(os.path.join(snap_path_extracted, binary_path), binary_abspath) + + data_abspath = os.path.join(rpm.buildroot, data_path) + rpmbuild.copy(os.path.join(snap_path_extracted, data_path), data_abspath) + + rpm.add_desktop_file(cmd="spotify %U", icon="/usr/share/spotify/icons/spotify_icon.ico", + Categories="Audio;Music;Player;AudioVideo;", + MimeType="x-scheme-handler/spotify" + ) + + rpm.build() + opi.install_packages([rpm.rpmfile_path], allow_unsigned=True) diff --git a/opi/rpmbuild.py b/opi/rpmbuild.py index cb50ca3..cd0fe5a 100644 --- a/opi/rpmbuild.py +++ b/opi/rpmbuild.py @@ -1,81 +1,117 @@ import os import tempfile -import textwrap import re import subprocess import glob +import shutil + +def copy(src, dst): + """ + Copy src to dst using hardlinks. + dst will be the full final path. + Directories will be created as needed. + """ + if os.path.islink(src) or os.path.isfile(src): + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.islink(src): + link_target = os.readlink(src) + os.symlink(link_target, dst) + elif os.path.isfile(src): + shitil.copy2(src, dst) + else: + shutil.copytree(src, dst, copy_function=os.link, symlinks=True, ignore_dangling_symlinks=False) + +def dedent(s): + """ Other than textwrap's implementation this one has no problems with some lines unindented. + It will unconditionally strip any leading whitespaces for each line. + """ + return re.sub(r"^\s*", "", s, flags=re.M) class RPMBuild: - def __init__(self, name, version, description, buildarch="noarch", files=[], dirs=[], config=[]): + def __init__(self, name, version, description, buildarch="noarch", + requires=[], recommends=[], provides=[], suggests=[], conflicts=[], autoreq=True, + files=[], dirs=[], config=[]): self.name = name self.version = version self.description = description self.buildarch = buildarch + self.requires = requires + self.recommends = recommends + self.provides = provides + self.suggests = suggests + self.conflicts = conflicts + self.autoreq = autoreq self.files = files self.dirs = dirs self.config = config self.tmpdir = tempfile.TemporaryDirectory() - self.src_root_dir = os.path.join(self.tmpdir.name, "root") + self.buildroot = os.path.join(self.tmpdir.name, "buildroot") # buildroot where plugins copy files to self.spec_path = os.path.join(self.tmpdir.name, "specfile.spec") - self.tmp_buildroot_dir = os.path.join(self.tmpdir.name, "buildroot") + self.rpmbuild_buildroot = os.path.join(self.tmpdir.name, "rpmbuildroot") # buildroot internally used by rpmbuild self.rpm_out_dir = os.path.join(self.tmpdir.name, "rpms") - os.mkdir(self.src_root_dir) + os.mkdir(self.buildroot) os.mkdir(self.rpm_out_dir) - @staticmethod - def _mkspec(name, version, description, buildarch="noarch", files=[], dirs=[], config=[]): + def mkspec(self): nl = "\n" - spec = re.sub(r"^\s*", "", f""" - Name: {name} - Version: {version} + spec = dedent(f""" + Name: {self.name} + Version: {self.version} Release: 0 - Summary: {description} + Summary: {self.description} License: n/a - BuildArch: {buildarch} + BuildArch: {self.buildarch} + {nl.join(f"Requires: {r}" for r in self.requires)} + {nl.join(f"Recommends: {r}" for r in self.recommends)} + {nl.join(f"Provides: {r}" for r in self.provides)} + {nl.join(f"Suggests: {r}" for r in self.suggests)} + {nl.join(f"Conflicts: {r}" for r in self.conflicts)} + {"AutoReq: no" if not self.autoreq else ''} %description - {description} + {self.description} Built locally using OPI. %install - cp -lav ./root/* %{{buildroot}}/ + cp -lav ./buildroot/* %{{buildroot}}/ %files - {nl.join(files)} - {nl.join(f"%dir {d}" for d in dirs)} - {nl.join(f"%config {c}" for c in config)} + {nl.join(self.files)} + {nl.join(f"%dir {d}" for d in self.dirs)} + {nl.join(f"%config {c}" for c in self.config)} %changelog - """, flags=re.M) + """) return spec - def add_desktop_file(self, cmd, icon): - os.makedirs(os.path.join(self.src_root_dir, 'usr/share/applications')) + def add_desktop_file(self, cmd, icon, **kwargs): + os.makedirs(os.path.join(self.buildroot, 'usr/share/applications')) desktop_path = f'usr/share/applications/{self.name}.desktop' - desktop_abspath = os.path.join(self.src_root_dir, desktop_path) + desktop_abspath = os.path.join(self.buildroot, desktop_path) self.files.append(f"/{desktop_path}") with open(desktop_abspath, 'w') as f: - f.write(textwrap.dedent(f""" + nl = "\n" + f.write(dedent(f""" [Desktop Entry] Name={self.name} Comment={self.description} Exec={cmd} Icon={icon} Type=Application + {nl.join(["%s=%s" % (k, v) for k, v in kwargs.items()])} """)) def build(self): print(f"Creating RPM for {self.name}") with open(self.spec_path, 'w') as f: - spec = type(self)._mkspec(self.name, self.version, self.description, self.buildarch, - self.files, self.dirs, self.config) + spec = self.mkspec() f.write(spec) subprocess.check_call([ "rpmbuild", "-bb", "--build-in-place", - "--buildroot", self.tmp_buildroot_dir, + "--buildroot", self.rpmbuild_buildroot, "--define", f"_rpmdir {self.rpm_out_dir}", "specfile.spec" ], cwd=self.tmpdir.name) diff --git a/opi/snap.py b/opi/snap.py new file mode 100644 index 0000000..315a6fb --- /dev/null +++ b/opi/snap.py @@ -0,0 +1,20 @@ +import subprocess +import requests +import opi + +def http_get_json(url): + r = requests.get(url, headers={'Snap-Device-Series': '16'}) + r.raise_for_status() + return r.json() + +def get_snap(snap, channel='stable', arch=None): + channels = http_get_json(f'https://api.snapcraft.io/v2/snaps/info/{snap}')['channel-map'] + if arch: + arch.replace('x86_64', 'amd64') + channels = [c for c in channels if c['channel']['architecture'] == arch] + channels = [c for c in channels if c['channel']['name'] == channel] + c = channels[0] + return {"version": c['version'], "url": c['download']['url']} + +def extract_snap(snap, target_dir): + subprocess.check_call(['unsquashfs', '-d', target_dir, snap])