diff --git a/flake.nix b/flake.nix index 64ebfc9..8424972 100644 --- a/flake.nix +++ b/flake.nix @@ -3,33 +3,36 @@ inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; - outputs = { nixpkgs, ... }: - let - pkgs = nixpkgs.legacyPackages.x86_64-linux; - lib = nixpkgs.lib; - in - { - devShells.x86_64-linux.default = pkgs.mkShellNoCC { - name = "leek-devshell"; - packages = with pkgs; [ - ruff - ( - python3.withPackages (ps: with ps;[ - python-lsp-server - pylsp-mypy - mypy - setuptools-scm - pyside6 - tomlkit - ] - ) - ) - ]; - }; - packages.x86_64-linux = rec { - default = leek; - leek = pkgs.callPackage ./package.nix { }; - }; - }; -} + outputs = {nixpkgs, ...}: let + pkgs = nixpkgs.legacyPackages.x86_64-linux; + lib = nixpkgs.lib; + in { + devShells.x86_64-linux.default = pkgs.mkShellNoCC { + name = "leek-devshell"; + packages = with pkgs; [ + ruff + ( + python3.withPackages ( + ps: + with ps; [ + # Dev dependencies + python-lsp-server + pylsp-mypy + mypy + setuptools-scm + #App dependencies + pyside6 + tomlkit + vdf + ] + ) + ) + ]; + }; + packages.x86_64-linux = rec { + default = leek; + leek = pkgs.callPackage ./package.nix {}; + }; + }; +} diff --git a/package.nix b/package.nix index febd6e7..48f4f64 100644 --- a/package.nix +++ b/package.nix @@ -1,10 +1,11 @@ { kdePackages, python3Packages, - qt6 -}: python3Packages.buildPythonApplication rec { + qt6, +}: +python3Packages.buildPythonApplication rec { pname = "leek"; - version = "0.1"; + version = "0.3"; pyproject = true; src = ./.; @@ -14,10 +15,10 @@ setuptools-scm ]; - dependencies = with python3Packages; [ pyside6 tomlkit + vdf ]; nativeBuildInputs = [ @@ -26,7 +27,7 @@ propagatedBuildInputs = [ kdePackages.kirigami - ]; + ]; makeWrapperArgs = [ "\${qtWrapperArgs[@]}" diff --git a/pyproject.toml b/pyproject.toml index a7857ca..1ccbed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,9 @@ classifiers = [ dependencies = [ "pyside6", "tomlkit", + "vdf", ] -requires-python = ">=3.6" +requires-python = ">=3.12" [project.readme] file = "README.md" @@ -47,7 +48,8 @@ mypy_path = "$MYPY_CONFIG_FILE_DIR/src" module = [ "PySide6.QtGui", "PySide6.QtCore", - "PySide6.QtQml" + "PySide6.QtQml", + "vdf" ] ignore_missing_imports = true diff --git a/src/leek/leek_app.py b/src/leek/leek_app.py index 5a93db9..f85592e 100644 --- a/src/leek/leek_app.py +++ b/src/leek/leek_app.py @@ -3,15 +3,16 @@ import os import sys import signal -from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QApplication from PySide6.QtCore import QUrl from PySide6.QtQml import QQmlApplicationEngine from leek.mod_list import QAbstractListModel # noqa: F401, needs to be imported for QML +from leek.qmod_installer import QModInstaller # noqa: F401, needs to be imported for QML def main(): """Initializes and manages the application execution""" - app: QGuiApplication = QGuiApplication(sys.argv) + app: QApplication = QApplication(sys.argv) app.setDesktopFileName("xyz.toast003.leek") app.setApplicationName("Leek") engine = QQmlApplicationEngine() diff --git a/src/leek/mod_installer.py b/src/leek/mod_installer.py new file mode 100644 index 0000000..485d93f --- /dev/null +++ b/src/leek/mod_installer.py @@ -0,0 +1,118 @@ +from pathlib import Path +from zipfile import Path as ZipPath +from zipfile import ZipFile, is_zipfile +from leek.utils import GameFinder + + +class ModInstaller: + __archive_path: Path + __archive_format: str + __is_flat: bool + __is_installed: bool + + def __init__(self, mod_path: Path) -> None: + self.__archive_path = mod_path + self.__is_installed = False + if is_zipfile(mod_path): + self.__archive_format = "zip" + archive: ZipFile = ZipFile(mod_path) + path: ZipPath = ZipPath(archive) + + # Make sure archive contains a mod + things_in_root: list[ZipPath] = list(path.iterdir()) + config_toml: ZipPath + match len(things_in_root): + case 0: + raise EmptyArchiveError + case 1: + config_toml = things_in_root[0] / "config.toml" + if config_toml.exists() and config_toml.is_file(): + # Mod can be extracted as is + self.__is_flat = False + self.__check_if_already_installed() + return + case _: + config_toml = path / "config.toml" + if config_toml.exists() and config_toml.is_file(): + # Mod needs to be extracted in a folder + self.__is_flat = True + self.__check_if_already_installed() + return + + # If we ever get here there's either no mod on the archive, or we failed to find it + raise NoModExceptionError(str(mod_path)) + + else: + raise UnsupportedArchiveTypeError(str(mod_path)) + + @property + def is_installed(self) -> bool: + return self.__is_installed + + def install(self) -> None: + """ + Install the mod to Project Diva's mod folder + """ + if self.__is_installed: + return + + game_path: Path = GameFinder.find() + if self.__is_flat: + mod_folder_name: str = self.__get_mod_folder_name() + ZipFile(self.__archive_path).extractall( + path=str(game_path / "mods" / mod_folder_name) + ) + else: + ZipFile(self.__archive_path).extractall(path=str(game_path / "mods")) + + self.__is_installed = True + + def __get_mod_folder_name(self) -> str: + mod_folder_name: str + if self.__is_flat: + mod_folder_name = self.__archive_path.stem + else: + path: ZipPath = ZipPath(ZipFile(self.__archive_path)) + things_in_root: list[ZipPath] = list(path.iterdir()) + mod_folder_name = things_in_root[0].stem + + return mod_folder_name + + def __check_if_already_installed(self) -> None: + installed_mod_path: Path = ( + GameFinder.find() / "mods" / self.__get_mod_folder_name() + ) + if installed_mod_path.exists(): + self.__is_installed = True + + +class UnsupportedArchiveTypeError(Exception): + """ + Rased when ModInstaller runs into an archive that it can't extract + """ + + __message: str + + def __init__(self, archive_path: str) -> None: + self.__message = f"Don't know how to unpack archive at {archive_path}" + + def __str__(self) -> str: + return f"{self.__message}" + + +class EmptyArchiveError(Exception): + pass + + +class NoModExceptionError(Exception): + """ + Raised if the archive does not have a mod inside + """ + + __message: str + + def __init__(self, archive_path: str) -> None: + self.__message = f"Archive at {archive_path} does not have a mod" + + def __str__(self) -> str: + return f"{self.__message}" diff --git a/src/leek/mod_list.py b/src/leek/mod_list.py index 5357bba..3c13d58 100644 --- a/src/leek/mod_list.py +++ b/src/leek/mod_list.py @@ -1,28 +1,26 @@ -from PySide6.QtQml import QmlElement +from PySide6.QtQml import QmlElement, QmlSingleton from PySide6.QtCore import QAbstractListModel, QModelIndex from leek.mod import InvalidModError from leek.qmod import QMod from pathlib import Path +from leek.utils import GameFinder QML_IMPORT_NAME = "Leek" QML_IMPORT_MAJOR_VERSION = 1 -# TODO: Don't harcode the mods path -GAME_PATH = Path( - "/home/toast/.local/share/Steam/steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus/" -) -MOD_PATH = Path(GAME_PATH, "mods") - # Qt follows C++ naming conventions # ruff: noqa: N802 @QmlElement +@QmlSingleton class QModListModel(QAbstractListModel): def __init__(self, parent=None) -> None: super().__init__(parent=parent) mods: list[QMod] = [] - for dir in MOD_PATH.iterdir(): + mod_path: Path = GameFinder.find() / "mods" + + for dir in mod_path.iterdir(): if dir.name == ".stfolder": continue try: @@ -50,3 +48,16 @@ class QModListModel(QAbstractListModel): else: result = None return result + + def removeRows(self, row, count, parent=QModelIndex()) -> bool: + super().beginRemoveRows(parent, row, row + count - 1) + for index in range(row, row + count): + deleted_mod: QMod = self.mods.pop(index) + for root, dirs, files in deleted_mod.pathlib_path.walk(top_down=False): + for name in files: + (root / name).unlink() + for name in dirs: + (root / name).rmdir() + deleted_mod.pathlib_path.rmdir() + super().endRemoveRows() + return False diff --git a/src/leek/qml/Main.qml b/src/leek/qml/Main.qml index a06690e..cf6889b 100644 --- a/src/leek/qml/Main.qml +++ b/src/leek/qml/Main.qml @@ -1,30 +1,132 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls as Controls +import QtQuick.Dialogs as Dialogs import org.kde.kirigami as Kirigami import Leek +import Leek.QModInstaller Kirigami.ApplicationWindow { id: root title: "Leek" + globalDrawer: Kirigami.GlobalDrawer { + actions: [ + Kirigami.Action { + // download-symbolic and install-symbolic are the same icon + // but install looks worse for some reason + id: addAction + icon.name: "download-symbolic" + text: "Add mod" + shortcut: StandardKey.New + onTriggered: modFileDialog.open() + }, + Kirigami.Action { + text: "Quit" + icon.name: "application-exit-symbolic" + shortcut: StandardKey.Quit + onTriggered: Qt.quit() + } + ] + isMenu: true + } + + Component.onCompleted: { + QModInstaller.statusChanged.connect(installerStatusChanged); + QModInstaller.finishedInstall.connect(installDialog.close); + } + + function installerStatusChanged() { + switch (QModInstaller.status) { + case "initialized": + if (QModInstaller.installed) { + installInfoMessage.text = "This mod is already installed!"; + installInfoDialog.open(); + } else { + installDialog.open(); + QModInstaller.install(); + } + break; + case "NoModExceptionError": + installInfoMessage.text = "This file does not have a mod"; + installInfoDialog.open(); + break; + case "UnsupportedArchiveTypeError": + installInfoMessage.text = "I don't know how to unpack this file, sorry :("; + installInfoDialog.open(); + break; + } + } + + Dialogs.FileDialog { + id: modFileDialog + + nameFilters: ["Project DIVA Mods (*.zip *.7z *.rar)"] + selectedNameFilter.index: 0 + + onAccepted: { + QModInstaller.modPath = selectedFile; + } + } + + Kirigami.Dialog { + id: installDialog + title: "Installing…" + // standardButtons: Kirigami.Dialog.Ok + closePolicy: Controls.Popup.NoAutoClose + showCloseButton: false + padding: Kirigami.Units.largeSpacing + Kirigami.LoadingPlaceholder { + // Layout.alignment: Qt.AlignCenter + anchors.centerIn: parent + determinate: false + text: "Unpacking…" + } + } + + Kirigami.Dialog { + id: installInfoDialog + title: "Add mod" + standardButtons: Kirigami.Dialog.Ok + padding: Kirigami.Units.largeSpacing + Controls.Label { + id: installInfoMessage + text: "You should never see this text" + } + } + + pageStack.columnView.columnResizeMode: Kirigami.ColumnView.SingleColumn pageStack.initialPage: Kirigami.ScrollablePage { title: "Mods" Kirigami.CardsListView { id: modsView - delegate: ModCardDelegate { - } - model: QModListModel { + delegate: ModCardDelegate {} + model: QModListModel + + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + icon.name: "edit-none" + visible: modsView.count === 0 + text: "There's no mods installed" + explanation: "Install a mod, and it will show up here" + helpfulAction: addAction } } } component ModCardDelegate: Kirigami.AbstractCard { + required property int index required property QMod mod + showClickFeedback: true + onClicked: pageStack.push(Qt.resolvedUrl("ModPage.qml"), { + mod: mod, + index: index + }) + // headerOrientation: Qt.Horizontal contentItem: Item { implicitHeight: modCardLayout.implicitHeight @@ -81,19 +183,6 @@ Kirigami.ApplicationWindow { onClicked: mod.enabled = checked } - Controls.Button { - text: "Delete" - onClicked: notImplementedDialog.open() - Kirigami.Dialog { - id: notImplementedDialog - title: "Not implemented!" - standardButtons: Kirigami.Dialog.Ok - padding: Kirigami.Units.largeSpacing - Controls.Label { - text: "Deleting is not implemented yet" - } - } - } } } } diff --git a/src/leek/qml/ModPage.qml b/src/leek/qml/ModPage.qml new file mode 100644 index 0000000..528affb --- /dev/null +++ b/src/leek/qml/ModPage.qml @@ -0,0 +1,139 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as Controls +import org.kde.kirigami as Kirigami +import Leek + +Kirigami.Page { + id: root + required property int index + required property QMod mod + + title: "Local mod" + + onBackRequested: event => { + event.accepted = true; + applicationWindow().pageStack.pop(); + } + + Kirigami.Dialog { + id: deleteConfirmationDialog + title: "Delete mod" + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + padding: Kirigami.Units.largeSpacing + Controls.Label { + text: "Permanently delete this mod?" + } + onAccepted: { + QModListModel.removeRows(index, 1); + applicationWindow().pageStack.pop(); + } + onOpened: { + const deleteButton = standardButton(Kirigami.Dialog.Cancel); + deleteButton.forceActiveFocus(); + } + } + + actions: [ + Kirigami.Action { + text: "Delete" + icon.name: "delete-symbolic" + shortcut: StandardKey.Delete + onTriggered: deleteConfirmationDialog.open() + } + ] + + ColumnLayout { + anchors { + top: parent.top + left: parent.left + right: parent.right + } + + Rectangle { + id: header + Layout.fillWidth: true + + Layout.topMargin: -root.topPadding + Layout.leftMargin: -root.leftPadding + Layout.rightMargin: -root.rightPadding + + Kirigami.ImageColors { + id: iconColors + source: modIcon.source + } + + implicitHeight: headerContents.implicitHeight + (headerContents.anchors.topMargin * 2) + + // Tint the header with the dominant color of the mod's icon + color: Kirigami.ColorUtils.tintWithAlpha(Kirigami.Theme.backgroundColor, iconColors.dominant, 0.1) + + GridLayout { + id: headerContents + anchors { + top: parent.top + left: parent.left + right: parent.right + topMargin: root.padding + bottomMargin: root.padding + leftMargin: root.padding + rightMargin: root.padding + } + + RowLayout { + // Icon + Kirigami.Icon { + id: modIcon + implicitHeight: Kirigami.Units.iconSizes.huge + implicitWidth: implicitHeight + source: "package-x-generic" + } + + // Name and author + ColumnLayout { + Kirigami.Heading { + text: mod.name + type: Kirigami.Heading.Type.Primary + } + Controls.Label { + property bool hasAuthors: mod.authors.length > 0 + function joinAuthors() { + return mod.authors.join(", "); + } + + text: hasAuthors ? joinAuthors() : "Unknown author" + } + } + } + } + + Kirigami.Separator { + width: header.width + anchors.top: header.bottom + } + } + + // Description + ColumnLayout { + Kirigami.Heading { + // TODO: this should be a short description of some sort + text: mod.name + type: Kirigami.Heading.Type.Primary + } + Controls.Label { + readonly property bool hasDescription: mod.longDescription || mod.description + text: { + if (hasDescription) { + if (mod.longDescription) { + text = mod.longDescription; + } else { + text = mod.description; + } + } else { + text = "The description for this mod is not available"; + } + } + } + } + } +} diff --git a/src/leek/qmod.py b/src/leek/qmod.py index a67c1dc..1131468 100644 --- a/src/leek/qmod.py +++ b/src/leek/qmod.py @@ -28,6 +28,14 @@ class QMod(QObject): # Pass though all exceptions raise + @property + def pathlib_path(self) -> Path: + return self.__mod.path + + @Property(str, constant=True) + def path(self) -> str: + return str(self.__mod.path) + @Property(str, constant=True) def name(self) -> str | None: return self.__mod.name @@ -40,6 +48,10 @@ class QMod(QObject): def longDescription(self) -> str | None: return self.__mod.long_description + @Property(list, constant=True) + def authors(self) -> list[str] | None: + return self.__mod.authors + mod_enabled = Signal(name="enabled") @Property(bool, notify=mod_enabled) diff --git a/src/leek/qmod_installer.py b/src/leek/qmod_installer.py new file mode 100644 index 0000000..7d7939f --- /dev/null +++ b/src/leek/qmod_installer.py @@ -0,0 +1,93 @@ +from pathlib import Path +from urllib.parse import urlparse + +from PySide6.QtCore import Property, QObject, QRunnable, QThreadPool, Signal, Slot +from PySide6.QtQml import QmlElement, QmlSingleton + +from leek.mod_installer import ( + ModInstaller, + NoModExceptionError, + UnsupportedArchiveTypeError, +) + +QML_IMPORT_NAME = "Leek.QModInstaller" +QML_IMPORT_MAJOR_VERSION = 1 + + +# Qt follows C++ naming conventions +# ruff: noqa: N802 +@QmlElement +@QmlSingleton +class QModInstaller(QObject): + """ + Qt Wrapper around the ModInstaller() class + """ + + __mod_installer: ModInstaller + __status: str + + def __init__(self, parent=None): + super().__init__(parent=parent) + self.__status = "uninitialized" + + mod_path_changed = Signal(name="modPathChanged") + + @Property(str, notify=mod_path_changed) + def modPath(self) -> str: + return self.test + + @modPath.setter # type: ignore[no-redef] + def modPath(self, value: str) -> None: + try: + # In python 3.13 pathlib gained a from_uri() method which would be very useful + parsed_path: str = urlparse(value, "file").path + self.__mod_installer = ModInstaller(Path(parsed_path)) + self.__status = "initialized" + self.status_changed.emit() + except UnsupportedArchiveTypeError: + self.__status = "UnsupportedArchiveTypeError" + self.status_changed.emit() + except NoModExceptionError: + self.__status = "NoModExceptionError" + self.status_changed.emit() + + install_status_changed = Signal(name="installStatusChanged") + + @Property(bool, notify=install_status_changed) + def installed(self) -> bool: + return self.__mod_installer.is_installed + + status_changed = Signal(name="statusChanged") + + @Property(str, notify=status_changed) + def status(self) -> str: + return self.__status + + finished_install = Signal(name="finishedInstall") + + @Slot() + def install(self) -> None: + worker = InstallWorker(InstallWorkerSignals(), self.__mod_installer) + worker.signals.installed.connect(self.finished_install) + QThreadPool.globalInstance().start(worker) + + +class InstallWorker(QRunnable): + __installer: ModInstaller + + def __init__(self, signals, installer: ModInstaller) -> None: + super().__init__() + self.signals = signals + self.__installer = installer + + # @Slot() + def run(self) -> None: + self.__installer.install() + self.signals.installed.emit() + + +class InstallWorkerSignals(QObject): + def __init__(self): + super().__init__() + + installed = Signal() diff --git a/src/leek/utils.py b/src/leek/utils.py new file mode 100644 index 0000000..c1d0bac --- /dev/null +++ b/src/leek/utils.py @@ -0,0 +1,32 @@ +import vdf +from pathlib import Path + + +class GameFinder: + __game_path: Path | None = None + + @staticmethod + def find() -> Path: + if GameFinder.__game_path is not None: + return GameFinder.__game_path + + # .local/share/Steam/config/libraryfolders.vdf + steam_path: Path = Path.home() / ".local/share/Steam" + libraries_vdf_path: Path = steam_path / "config/libraryfolders.vdf" + + with libraries_vdf_path.open() as vdf_file: + data: dict = vdf.parse(vdf_file) + for index in data["libraryfolders"]: + library: dict = data["libraryfolders"][index] + + project_diva_id: str = "1761390" + if project_diva_id in library["apps"]: + libray_path: Path = Path(library["path"]) + GameFinder.__game_path = ( + libray_path + / "steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus" + ) + return GameFinder.__game_path + + # Could not find the game :( + raise FileNotFoundError