diff --git a/.gitignore b/.gitignore index a5f38aa..09acfc8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ __pycache__/ *.pyc .venv/ *.egg-info/ +AppDir/ +appimage-build/ +appimage-builder-cache/ +*.AppImage +*.zsync diff --git a/AppImageBuilder.yml b/AppImageBuilder.yml new file mode 100644 index 0000000..b06f553 --- /dev/null +++ b/AppImageBuilder.yml @@ -0,0 +1,43 @@ +version: 1 + +AppDir: + path: ./AppDir + app_info: + id: ca.schantli.MHWildsModManager + name: MHWildsModManager + icon: ca.schantli.MHWildsModManager + version: 1.0.0 + exec: usr/bin/python3 + exec_args: -m mhwmm $@ + apt: + arch: amd64 + allow_unauthenticated: true + sources: + - sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ noble main restricted universe multiverse' + - sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ noble-updates main restricted universe multiverse' + include: + - python3 + - python3-gi + - gir1.2-gtk-4.0 + - gir1.2-adw-1 + - libglib2.0-bin + - libgtk-4-1 + - libadwaita-1-0 + - adwaita-icon-theme + - python3-requests + files: + include: [] + exclude: + - usr/share/man + - usr/share/doc/*/README.* + - usr/share/doc/*/changelog.* + - usr/share/doc/*/copyright + - usr/share/doc/*/TODO.* + runtime: + env: + PYTHONPATH: '${APPDIR}/usr/lib/python3.12/site-packages:${APPDIR}/usr/share/mhwmm' + +AppImage: + update-information: None + sign-key: None + arch: x86_64 diff --git a/build_appimage.sh b/build_appimage.sh new file mode 100755 index 0000000..017fd8e --- /dev/null +++ b/build_appimage.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +echo "Cleaning old AppDir..." +rm -rf AppDir MHWildsModManager*.AppImage + +echo "Creating AppDir structure..." +mkdir -p AppDir/usr/bin +mkdir -p AppDir/usr/share/mhwmm +mkdir -p AppDir/usr/share/applications +mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + +echo "Copying source files..." +cp -r src/mhwmm/* AppDir/usr/share/mhwmm/ +cat << 'WRAPPER' > AppDir/usr/bin/mhwmm +#!/bin/bash +export PYTHONPATH="${APPDIR}/usr/share/mhwmm:${PYTHONPATH}" +exec python3 -m mhwmm "$@" +WRAPPER +chmod +x AppDir/usr/bin/mhwmm + +echo "Copying desktop and icon..." +cp data/ca.schantli.MHWildsModManager.desktop AppDir/usr/share/applications/ +cp data/ca.schantli.MHWildsModManager.png AppDir/usr/share/icons/hicolor/256x256/apps/ +cp data/ca.schantli.MHWildsModManager.png AppDir/ca.schantli.MHWildsModManager.png + +python3 -c " +with open('AppDir/usr/share/applications/ca.schantli.MHWildsModManager.desktop', 'r') as df: + d = df.read() +d = d.replace('Exec=bin/mhwmm', 'Exec=python3') +with open('AppDir/usr/share/applications/ca.schantli.MHWildsModManager.desktop', 'w') as df: + df.write(d) +" +echo "Building AppImage using Podman (ubuntu:24.04)..." + +podman run --rm -i --privileged -v $(pwd):/workspace -w /workspace ubuntu:24.04 bash -c "apt-get update && apt-get install -y wget file squashfs-tools binutils gnupg2 gtk-update-icon-cache && wget -q -O /tmp/appimage-builder https://github.com/AppImageCrafters/appimage-builder/releases/download/v1.1.0/appimage-builder-1.1.0-x86_64.AppImage && chmod +x /tmp/appimage-builder && cd /tmp && ./appimage-builder --appimage-extract && cd /workspace && /tmp/squashfs-root/AppRun --recipe AppImageBuilder.yml --skip-test" + +echo "Done! AppImage is ready." diff --git a/data/ca.schantli.MHWildsModManager.png b/data/ca.schantli.MHWildsModManager.png new file mode 100644 index 0000000..191ccc9 Binary files /dev/null and b/data/ca.schantli.MHWildsModManager.png differ diff --git a/src/mhwmm/core/manager.py b/src/mhwmm/core/manager.py index 73beb83..97b74ce 100644 --- a/src/mhwmm/core/manager.py +++ b/src/mhwmm/core/manager.py @@ -14,6 +14,7 @@ from .layout import PAK, Classification from .library import Library, Mod from .paths import Paths from .profiles import Profiles +from .modpack import export_modpack, import_modpack @dataclass @@ -185,6 +186,17 @@ class Manager: self.profiles.save() return self.deploy() if self.deployer else None + def export_modpack(self, dest_path: Path, profile_name: str | None = None) -> None: + profile_name = profile_name or self.profiles.active + export_modpack(self, dest_path, profile_name) + + def import_modpack(self, source_path: Path, profile_name: str | None = None) -> list[str]: + profile_name = profile_name or source_path.stem + res = import_modpack(self, source_path, profile_name) + if self.deployer and self.settings.auto_deploy: + self.deploy() + return res + def conflicts(self) -> list[Conflict]: return self.last_desired.conflicts if self.baseline else [] diff --git a/src/mhwmm/core/modpack.py b/src/mhwmm/core/modpack.py new file mode 100644 index 0000000..ab51501 --- /dev/null +++ b/src/mhwmm/core/modpack.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import zipfile +from pathlib import Path +from typing import TYPE_CHECKING + +from .library import Mod, LibraryError + +if TYPE_CHECKING: + from .manager import Manager + +def export_modpack(manager: Manager, dest_path: Path, profile_name: str) -> None: + profile = manager.profiles.data.get(profile_name) + if not profile: + raise ValueError(f"Profile {profile_name} not found") + + with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf: + # Export profile metadata + zf.writestr("modpack.json", json.dumps({"name": profile_name, "profile": profile}, indent=2)) + + # Export mods + mods_to_export = set(profile["order"]) + for mod_id in mods_to_export: + mod = manager.library.get(mod_id) + if not mod: + continue + + # Write mod files + mod_root = mod.root + for dirpath, dirnames, filenames in os.walk(mod_root): + for filename in filenames: + file_path = Path(dirpath) / filename + rel_path = file_path.relative_to(mod_root) + zf.write(file_path, f"mods/{mod_id}/{rel_path}") + +def import_modpack(manager: Manager, source_path: Path, new_profile_name: str) -> list[str]: + with zipfile.ZipFile(source_path, "r") as zf: + try: + with zf.open("modpack.json") as f: + data = json.load(f) + profile = data.get("profile") + except KeyError: + raise ValueError("Not a valid modpack: missing modpack.json") + + if not profile: + raise ValueError("Invalid modpack.json") + + # Extract mods + extracted_mods = [] + for name in zf.namelist(): + if name.startswith("mods/") and len(name.split("/")) > 2: + parts = name.split("/") + mod_id = parts[1] + if mod_id not in manager.library.mods: + # Not in library, we need to extract it + dest_file = manager.paths.mods_dir / Path(*parts[1:]) + dest_file.parent.mkdir(parents=True, exist_ok=True) + if not name.endswith("/"): + with zf.open(name) as source, open(dest_file, "wb") as target: + shutil.copyfileobj(source, target) + if mod_id not in extracted_mods: + extracted_mods.append(mod_id) + + # Reload library to discover the new mods + manager.reload_library() + + # Ensure profile name is unique + final_name = new_profile_name + n = 2 + while final_name in manager.profiles.data: + final_name = f"{new_profile_name} ({n})" + n += 1 + + manager.profiles.data[final_name] = profile + manager.profiles.switch(final_name) + manager.profiles.save() + + return extracted_mods diff --git a/src/mhwmm/ui/window.py b/src/mhwmm/ui/window.py index de4b39a..51ef525 100644 --- a/src/mhwmm/ui/window.py +++ b/src/mhwmm/ui/window.py @@ -83,7 +83,8 @@ class MainWindow(Adw.ApplicationWindow): menu.append_section(None, section) prof = Gio.Menu() for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"), - ("Delete Profile", "win.delete-profile")): + ("Delete Profile", "win.delete-profile"), ("Export Modpack…", "win.export-modpack"), + ("Import Modpack…", "win.import-modpack")): prof.append(label, action) menu.append_section("Profiles", prof) tail = Gio.Menu() @@ -151,6 +152,8 @@ class MainWindow(Adw.ApplicationWindow): add("new-profile", self._new_profile) add("rename-profile", self._rename_profile) add("delete-profile", self._delete_profile) + add("export-modpack", self._export_modpack) + add("import-modpack", self._import_modpack) add("move-up", lambda: self._move_selected(-1)) add("move-down", lambda: self._move_selected(+1)) app = self.get_application() @@ -432,6 +435,79 @@ class MainWindow(Adw.ApplicationWindow): return confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True) + def _export_modpack(self) -> None: + d = Gtk.FileDialog(title="Export Modpack", initial_name=f"{self.m.profiles.active}.zip") + def done(dlg, res): + try: + f = dlg.save_finish(res) + except GLib.Error: + return + path = Path(f.get_path()) + if not path.name.endswith(".zip"): + path = path.with_name(path.name + ".zip") + + self._busy += 1 + self.toast(f"Exporting modpack {self.m.profiles.active}...", 2) + + def work(): + try: + self.m.export_modpack(path) + return True, None + except Exception as e: + return False, str(e) + + def finish(res, err): + self._busy -= 1 + success, error = res + if error: + self.toast(f"Export failed: {error}", 10) + else: + self.toast(f"Modpack exported to {path.name}", 8) + + run_async(work, finish) + + d.save(self, None, done) + + def _import_modpack(self) -> None: + d = Gtk.FileDialog(title="Import Modpack") + flt = Gtk.FileFilter() + flt.set_name("Modpacks (.zip)") + flt.add_pattern("*.zip") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(flt) + d.set_filters(filters) + d.set_default_filter(flt) + + def done(dlg, res): + try: + f = dlg.open_finish(res) + except GLib.Error: + return + path = Path(f.get_path()) + + self._busy += 1 + self.toast(f"Importing modpack...", 2) + + def work(): + try: + mods = self.m.import_modpack(path) + return mods, None + except Exception as e: + return None, str(e) + + def finish(res, err): + self._busy -= 1 + mods, error = res + if error: + self.toast(f"Import failed: {error}", 10) + else: + self.toast(f"Imported modpack with {len(mods)} mods.", 8) + self.refresh_all() + + run_async(work, finish) + + d.open(self, None, done) + # -- deploy ---------------------------------------------------------------------------------------------