import os
import tempfile
import shutil
import debian.debfile
from debian.arfile import ArFile

from typing import List, Optional, Text


class DebPackage:
    def __init__(
        self,
        name: Text,
        description: Text,
        version: Text,
        dependencies: List[Text],
        installed_size: Optional[int] = 0,
    ):
        self.name = name
        self.description = description
        self.version = version
        self.dependencies = dependencies
        self.installed_size = installed_size

        self.installed_size_format = self.__format_size(self.installed_size)

    @staticmethod
    def __format_size(size: int) -> Text:
        if size < 1024:
            return f"{size} B"
        elif size < 1024**2:
            return f"{size / 1024:.2f} KB"
        elif size < 1024**3:
            return f"{size / 1024 ** 2:.2f} MB"
        elif size < 1024**4:
            return f"{size / 1024 ** 3:.2f} GB"
        else:
            return f"{size / 1024 ** 4:.2f} TB"


class DpkgResolver:
    def __init__(self, package_path: Text):
        self.package_path = package_path

    def extract_info(self) -> Optional[DebPackage]:
        with debian.debfile.DebFile(self.package_path) as deb_file:
            name = deb_file.debcontrol().get("Package", "")
            description = deb_file.debcontrol().get("Description", "")
            version = deb_file.debcontrol().get("Version", "")
            dependencies = deb_file.debcontrol().get("Depends", "").split(", ")
            installed_size = deb_file.debcontrol().get("Installed-Size", 0)

            installed_size = int(installed_size) if installed_size else 0

            return DebPackage(
                name=name,
                description=description,
                version=version,
                dependencies=dependencies,
                installed_size=installed_size,
            )

vanilla_sideload/backend/dpkg.py
Vanilla-OS/sideload-utility
from enum import Enum


class ValidSideloadAction(Enum):
    INSTALL = 1
    UNINSTALL = 2

vanilla_sideload/backend/types.py
Vanilla-OS/sideload-utility
# main.py
#
# Copyright 2023 Vanilla OS Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

import sys
import gi
import logging
from gettext import gettext as _

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Vte", "3.91")

from gi.repository import Gtk, GLib, Gio, Adw
from vanilla_sideload.window import SideloaderWindow
from vanilla_sideload.backend.types import ValidSideloadAction


logging.basicConfig(level=logging.INFO)


class SideloadApplication(Adw.Application):
    """The main application singleton class."""

    __requested_action: ValidSideloadAction
    __package_path: str

    def __init__(self):
        super().__init__(
            application_id="org.vanillaos.Sideload",
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
        )
        self.create_action("quit", self.quit, ["<primary>q"])

        self.__register_arguments()

    def __register_arguments(self):
        """Register command line arguments."""
        self.add_main_option(
            "install",
            ord("i"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            "Install a package",
            None,
        )
        self.add_main_option(
            "uninstall",
            ord("u"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.STRING,
            "Uninstall a package",
            None,
        )

    def do_command_line(self, command):
        """Handle command line arguments.

        We only have one command line option, --embedded, which
        indicates that the application is embedded in another
        application.
        """

        commands = command.get_options_dict()

        install_option = commands.lookup_value("install")
        uninstall_option = commands.lookup_value("uninstall")

        if install_option:
            self.__requested_action = ValidSideloadAction.INSTALL
            self.__package_path = install_option.get_string()
        elif uninstall_option:
            self.__requested_action = ValidSideloadAction.UNINSTALL
            self.__package_path = uninstall_option.get_string()
        else:
            print("No action specified")
            return 0

        return self.do_activate()

    def do_activate(self):
        """Called when the application is activated.

        We raise the application's main window, creating it if
        necessary.
        """
        win = self.props.active_window
        if not win:
            win = SideloaderWindow(
                pkg_path=self.__package_path,
                requested_action=self.__requested_action,
                application=self,
            )
        win.present()

    def create_action(self, name, callback, shortcuts=None):
        """Add an application action.

        Args:
            name: the name of the action
            callback: the function to be called when the action is
              activated
            shortcuts: an optional list of accelerators
        """
        action = Gio.SimpleAction.new(name, None)
        action.connect("activate", callback)
        self.add_action(action)
        if shortcuts:
            self.set_accels_for_action(f"app.{name}", shortcuts)


def main(version):
    """The application's entry point."""
    app = SideloadApplication()
    return app.run(sys.argv)

vanilla_sideload/main.py
Vanilla-OS/sideload-utility
# run_async.py
#
# Copyright 2023 Mirko Brombin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-only

import os
import sys
import threading
import traceback
import logging

from gi.repository import GLib

logger = logging.getLogger("Vanilla::Async")


class RunAsync(threading.Thread):
    """
    This class is used to execute a function asynchronously.
    It takes a function, a callback and a list of arguments as input.
    """

    def __init__(self, task_func, callback=None, *args, **kwargs):
        if "DEBUG_MODE" in os.environ:
            import faulthandler

            faulthandler.enable()

        self.source_id = None
        assert threading.current_thread() is threading.main_thread()

        super(RunAsync, self).__init__(target=self.__target, args=args, kwargs=kwargs)

        self.task_func = task_func

        self.callback = callback if callback else lambda r, e: None
        self.daemon = kwargs.pop("daemon", True)

        self.start()

    def __target(self, *args, **kwargs):
        result = None
        error = None

        logger.debug(f"Running async job [{self.task_func}].")

        try:
            result = self.task_func(*args, **kwargs)
        except Exception as exception:
            logger.error(
                "Error while running async job: "
                f"{self.task_func}\nException: {exception}"
            )

            error = exception
            _ex_type, _ex_value, trace = sys.exc_info()
            traceback.print_tb(trace)
            traceback_info = "\n".join(traceback.format_tb(trace))

        self.source_id = GLib.idle_add(self.callback, result, error)
        return self.source_id

vanilla_sideload/utils/run_async.py
Vanilla-OS/sideload-utility
# window.py
#
# Copyright 2023 Vanilla OS Contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later

import time
from gi.repository import Adw, Gtk
from typing import Text, Dict, Any, Callable

from vanilla_sideload.backend.dpkg import DpkgResolver, DebPackage
from vanilla_sideload.backend.types import ValidSideloadAction
from vanilla_sideload.utils.run_async import RunAsync


@Gtk.Template(resource_path="/org/vanillaos/Sideload/gtk/window.ui")
class SideloaderWindow(Adw.ApplicationWindow):
    __gtype_name__ = "SideloaderWindow"

    bin_main = Gtk.Template.Child()

    status_install = Gtk.Template.Child()
    status_uninstall = Gtk.Template.Child()
    status_loading = Gtk.Template.Child()
    status_install_done = Gtk.Template.Child()
    status_uninstall_done = Gtk.Template.Child()
    status_operation_failed = Gtk.Template.Child()

    btn_install = Gtk.Template.Child()
    btn_uninstall = Gtk.Template.Child()
    btn_open = Gtk.Template.Child()

    label_install_size = Gtk.Template.Child()

    progress_bar = Gtk.Template.Child()

    __pkg: DebPackage
    __must_pulse: bool = False

    def __init__(
        self,
        pkg_path: Text,
        requested_action: ValidSideloadAction,
        **kwargs: Dict[str, Any],
    ):
        super().__init__(**kwargs)
        self.__requested_action = requested_action
        self.__resolver = DpkgResolver(pkg_path)

        self.__build_ui()

    def __build_ui(self):
        def callback(result, error):
            self.__must_pulse = False

            if self.__requested_action == ValidSideloadAction.INSTALL:
                if error:
                    self.__build_error_ui(_("Failed reading package information"))
                    return
                self.__build_install_ui()

            if self.__requested_action == ValidSideloadAction.UNINSTALL:
                if error:
                    self.__build_error_ui(_("Failed reading package information"))
                    return
                self.__build_uninstall_ui()

        self.__build_loading_ui(
            title=_("Reading package information"),
            description=_("This may take a while..."),
        )

        async_func: Callable
        if self.__requested_action == ValidSideloadAction.INSTALL:
            async_func = self.__read_package_info
        elif self.__requested_action == ValidSideloadAction.UNINSTALL:
            async_func = self.__read_installed_package_info
        else:
            raise ValueError("Invalid action requested")

        RunAsync(
            async_func,
            callback=callback,
        )

    def __build_install_ui(self):
        self.btn_install.connect("clicked", self.__on_install_clicked)

        self.status_install.set_title(self.__pkg.name)
        self.status_install.set_description(self.__pkg.description)

        self.label_install_size.set_text(self.__pkg.installed_size_format)

        self.bin_main.set_child(self.status_install)

    def __build_uninstall_ui(self):
        self.btn_uninstall.connect("clicked", self.__on_uninstall_clicked)

        self.status_uninstall.set_title(self.__pkg.name)

        self.bin_main.set_child(self.status_uninstall)

    def __on_install_clicked(self, button: Gtk.Button):
        print("Install clicked")

    def __on_uninstall_clicked(self, button: Gtk.Button):
        print("Uninstall clicked")

    def __on_open_clicked(self, button: Gtk.Button):
        print("Open clicked")

    def __build_error_ui(self, error_message: Text):
        self.status_operation_failed.set_title(error_message)
        self.bin_main.set_child(self.status_operation_failed)

    def __read_package_info(self):
        self.__pkg = self.__resolver.extract_info()
        if not self.__pkg:
            raise ValueError("Unable to extract package information")

    def __read_installed_package_info(self):
        raise NotImplementedError("Not implemented yet")

    def __build_loading_ui(self, title: Text, description: Text):
        def pulse():
            while True:
                if not self.__must_pulse:
                    break

                self.progress_bar.pulse()
                time.sleep(0.1)

        self.bin_main.set_child(self.status_loading)

        self.status_loading.set_title(title)
        self.status_loading.set_description(description)

        self.progress_bar.set_pulse_step(0.1)
        self.__must_pulse = True

        RunAsync(pulse)

vanilla_sideload/window.py
Vanilla-OS/sideload-utility