feat: Add modpack import/export functionality

This commit is contained in:
jschantli committed 2026-09-30 19:09:26 -07:00
1 parent f780daecad
commit 848dac4264
7 files changed
+257 -1

No files matched your search

+5
View File
@@ -2,3 +2,8 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
*.egg-info/ *.egg-info/
AppDir/
appimage-build/
appimage-builder-cache/
*.AppImage
*.zsync
+43
View File
@@ -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
+38
View File
@@ -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."
Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

+12
View File
@@ -14,6 +14,7 @@ from .layout import PAK, Classification
from .library import Library, Mod from .library import Library, Mod
from .paths import Paths from .paths import Paths
from .profiles import Profiles from .profiles import Profiles
from .modpack import export_modpack, import_modpack
@dataclass @dataclass
@@ -185,6 +186,17 @@ class Manager:
self.profiles.save() self.profiles.save()
return self.deploy() if self.deployer else None 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]: def conflicts(self) -> list[Conflict]:
return self.last_desired.conflicts if self.baseline else [] return self.last_desired.conflicts if self.baseline else []
+82
View File
@@ -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
+77 -1
View File
@@ -83,7 +83,8 @@ class MainWindow(Adw.ApplicationWindow):
menu.append_section(None, section) menu.append_section(None, section)
prof = Gio.Menu() prof = Gio.Menu()
for label, action in (("New Profile…", "win.new-profile"), ("Rename Profile…", "win.rename-profile"), 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) prof.append(label, action)
menu.append_section("Profiles", prof) menu.append_section("Profiles", prof)
tail = Gio.Menu() tail = Gio.Menu()
@@ -151,6 +152,8 @@ class MainWindow(Adw.ApplicationWindow):
add("new-profile", self._new_profile) add("new-profile", self._new_profile)
add("rename-profile", self._rename_profile) add("rename-profile", self._rename_profile)
add("delete-profile", self._delete_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-up", lambda: self._move_selected(-1))
add("move-down", lambda: self._move_selected(+1)) add("move-down", lambda: self._move_selected(+1))
app = self.get_application() app = self.get_application()
@@ -432,6 +435,79 @@ class MainWindow(Adw.ApplicationWindow):
return return
confirm(self, f"Delete profile “{name}”?", "Mods themselves are kept.", "Delete", go, destructive=True) 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 --------------------------------------------------------------------------------------------- # -- deploy ---------------------------------------------------------------------------------------------