Files

750 lines
30 KiB
Python

"""Main window: Adw.NavigationSplitView with the mod list on the left and details on the right."""
from __future__ import annotations
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango
from ..core import nexus
from ..core.deploy import ApplyResult, DeployError
from ..core.library import LibraryError
from ..core.manager import Manager
from .details import DetailsPane
from .downloads import DownloadManager
from .dialogs import (DESTRUCTIVE, GameSetupWindow, PreferencesWindow, TextDialog, alert, confirm, preview_dialog,
prompt_text)
from .tasks import run_async
ARCHIVE_PATTERNS = ("*.zip", "*.7z", "*.rar", "*.ZIP", "*.7Z", "*.RAR", "*.pak", "*.PAK", "*.dll", "*.DLL", "*.lua", "*.LUA")
class MainWindow(Adw.ApplicationWindow):
def __init__(self, app: Adw.Application, manager: Manager):
super().__init__(application=app, title="MH Wilds Mod Manager", default_width=1120, default_height=740)
self.m = manager
self.keystore = nexus.KeyStore(manager.paths.secrets_file)
self.rows: dict[str, Gtk.ListBoxRow] = {}
self._search = ""
self._category = ""
self._busy = 0
self._syncing_profiles = False
self._syncing_categories = False
self.toasts = Adw.ToastOverlay()
self.set_content(self.toasts)
self.root_stack = Gtk.Stack()
self.toasts.set_child(self.root_stack)
self.root_stack.add_named(self._build_no_game(), "no-game")
self.root_stack.add_named(self._build_main(), "main")
self._install_actions()
self._install_file_drop()
GLib.idle_add(self._startup)
# -- construction -------------------------------------------------------------
def _build_no_game(self) -> Gtk.Widget:
page = Adw.StatusPage(icon_name="dialog-question-symbolic", title="Monster Hunter Wilds not found",
description="Steam does not list the game (app 2246340). Install it, or point the "
"manager at the game folder.")
box = Gtk.Box(spacing=12, halign=Gtk.Align.CENTER)
pick = Gtk.Button(label="Choose Game Folder…")
pick.add_css_class("suggested-action")
pick.add_css_class("pill")
pick.connect("clicked", lambda *_: self._choose_game_folder())
retry = Gtk.Button(label="Retry")
retry.add_css_class("pill")
retry.connect("clicked", lambda *_: self._startup())
box.append(pick)
box.append(retry)
page.set_child(box)
tv = Adw.ToolbarView(content=page)
tv.add_top_bar(Adw.HeaderBar())
return tv
def _build_main(self) -> Gtk.Widget:
self.split = Adw.NavigationSplitView(min_sidebar_width=300, max_sidebar_width=420,
sidebar_width_fraction=0.32)
# sidebar
header = Adw.HeaderBar()
self.dl_manager = DownloadManager()
dl_btn = Gtk.MenuButton(icon_name="folder-download-symbolic", tooltip_text="Downloads", popover=self.dl_manager.popover)
header.pack_start(dl_btn)
imp = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Import Mod…", action_name="win.import")
header.pack_start(imp)
menu = Gio.Menu()
section = Gio.Menu()
for label, action in (("Game Setup…", "win.setup"), ("Conflicts…", "win.conflicts"),
("Undeploy All Mods", "win.undeploy-all")):
section.append(label, action)
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"), ("Export Modpack…", "win.export-modpack"),
("Import Modpack…", "win.import-modpack")):
prof.append(label, action)
menu.append_section("Profiles", prof)
tail = Gio.Menu()
tail.append("Preferences", "win.prefs")
tail.append("Quit", "app.quit")
menu.append_section(None, tail)
mb = Gtk.MenuButton(icon_name="open-menu-symbolic", menu_model=menu, tooltip_text="Main menu")
header.pack_end(mb)
self.update_all_btn = Gtk.Button(label="Check Updates", tooltip_text="Check Nexus Mods for updates", action_name="win.update_all")
header.pack_end(self.update_all_btn)
self.profile_dd = Gtk.DropDown(model=Gtk.StringList.new([]), hexpand=True)
self.profile_dd.connect("notify::selected", self._on_profile_selected)
self.cat_dd = Gtk.DropDown(model=Gtk.StringList.new(["All categories"]))
self.cat_dd.connect("notify::selected", self._on_category_selected)
self.search = Gtk.SearchEntry(placeholder_text="Search mods", hexpand=True)
self.search.connect("search-changed", self._on_search)
controls = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, margin_start=8, margin_end=8,
margin_top=6, margin_bottom=6)
controls.append(self.profile_dd)
controls.append(self.search)
controls.append(self.cat_dd)
self.listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE)
self.listbox.add_css_class("navigation-sidebar")
self.listbox.set_filter_func(self._filter)
self.listbox.connect("row-selected", self._on_row_selected)
self.hint = Gtk.Label(label="Load order: later mods win conflicts. Drag ⠿ or Alt+↑/↓ to reorder.",
wrap=True, xalign=0, margin_start=12, margin_end=12, margin_bottom=8)
self.hint.add_css_class("caption")
self.hint.add_css_class("dim-label")
side_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
side_box.append(controls)
side_box.append(Gtk.ScrolledWindow(child=self.listbox, vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER))
side_box.append(self.hint)
stv = Adw.ToolbarView(content=side_box)
stv.add_top_bar(header)
self.split.set_sidebar(Adw.NavigationPage.new(stv, "Mods"))
# content
self.details = DetailsPane(self.m, self._on_mod_edited, self._delete_mod, self._choose_dll,
lambda: self.activate_action("win.import", None))
chead = Adw.HeaderBar()
up = Gtk.Button(icon_name="go-up-symbolic", tooltip_text="Move up (lower priority) Alt+↑", action_name="win.move-up")
down = Gtk.Button(icon_name="go-down-symbolic", tooltip_text="Move down (higher priority) Alt+↓",
action_name="win.move-down")
chead.pack_end(down)
chead.pack_end(up)
ctv = Adw.ToolbarView(content=self.details)
ctv.add_top_bar(chead)
self.split.set_content(Adw.NavigationPage.new(ctv, "Details"))
return self.split
def _install_actions(self) -> None:
def add(name, cb):
a = Gio.SimpleAction.new(name, None)
a.connect("activate", lambda *_: cb())
self.add_action(a)
add("import", self._import_dialog)
add("update_all", self._check_updates_all)
add("undeploy-all", self._undeploy_all)
add("setup", self._open_setup)
add("conflicts", self._show_conflicts)
add("prefs", self._open_prefs)
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()
app.set_accels_for_action("win.move-up", ["<Alt>Up"])
app.set_accels_for_action("win.move-down", ["<Alt>Down"])
app.set_accels_for_action("win.import", ["<Primary>o"])
def _install_file_drop(self) -> None:
target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY)
target.connect("drop", self._on_files_dropped)
self.add_controller(target)
# -- startup / refresh -----------------------------------------------------------------
def toast(self, msg: str, timeout: int = 4) -> None:
t = Adw.Toast.new(msg)
t.set_timeout(timeout)
self.toasts.add_toast(t)
def _startup(self) -> bool:
try:
rep = self.m.startup()
except Exception as e:
self.toast(f"Startup problem: {e}", 10)
rep = None
if rep is None or not rep.game_found:
self.root_stack.set_visible_child_name("no-game")
return False
self.root_stack.set_visible_child_name("main")
for n in rep.notes:
self.toast(n, 12)
self.refresh_all()
return False
def refresh_all(self) -> None:
self._recompute()
self._fill_profiles()
self._fill_categories()
self._fill_list()
def _recompute(self) -> None:
try:
self.m.desired()
except DeployError:
pass
def _fill_profiles(self) -> None:
self._syncing_profiles = True
names = self.m.profiles.names
self.profile_dd.set_model(Gtk.StringList.new(names))
self.profile_dd.set_selected(names.index(self.m.profiles.active))
self._syncing_profiles = False
def _fill_categories(self) -> None:
self._syncing_categories = True
cats = ["All categories", *self.m.library.categories()]
self.cat_dd.set_model(Gtk.StringList.new(cats))
self.cat_dd.set_selected(cats.index(self._category) if self._category in cats else 0)
if self._category not in cats:
self._category = ""
self._syncing_categories = False
def _fill_list(self) -> None:
keep = self.details.mod_id
while (child := self.listbox.get_first_child()) is not None:
self.listbox.remove(child)
self.rows.clear()
for mid in self.m.profiles.order():
mod = self.m.library.get(mid)
if mod:
row = self._make_row(mod)
self.rows[mid] = row
self.listbox.append(row)
if keep and keep in self.rows:
self.listbox.select_row(self.rows[keep])
else:
self.details.clear()
def _make_row(self, mod) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow()
row.mod_id = mod.id
box = Gtk.Box(spacing=8, margin_start=4, margin_end=4, margin_top=6, margin_bottom=6)
handle = Gtk.Image.new_from_icon_name("list-drag-handle-symbolic")
handle.set_tooltip_text("Drag to change load order")
src = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
src.connect("prepare", lambda _s, _x, _y, mid=mod.id: Gdk.ContentProvider.new_for_value(mid))
handle.add_controller(src)
box.append(handle)
check = Gtk.CheckButton(active=self.m.profiles.is_enabled(mod.id), valign=Gtk.Align.CENTER)
check.connect("toggled", self._on_toggled, mod.id)
box.append(check)
row.check = check
text = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, hexpand=True)
name = Gtk.Label(label=mod.name, xalign=0, ellipsize=Pango.EllipsizeMode.END)
sub = Gtk.Label(label=f"v{mod.version}" if mod.version else " ", xalign=0)
sub.add_css_class("caption")
sub.add_css_class("dim-label")
text.append(name)
text.append(sub)
box.append(text)
if mod.category:
badge = Gtk.Label(label=mod.category)
badge.add_css_class("caption")
badge.add_css_class("accent")
box.append(badge)
warn = Gtk.Image.new_from_icon_name("dialog-warning-symbolic")
warn.add_css_class("warning")
box.append(warn)
row.warn = warn
self._update_warn(row)
drop = Gtk.DropTarget.new(GObject.TYPE_STRING, Gdk.DragAction.MOVE)
drop.connect("drop", lambda _t, value, _x, _y, mid=mod.id: self._on_row_drop(value, mid))
row.add_controller(drop)
row.set_child(box)
return row
def _update_warn(self, row) -> None:
confs = self.m.conflicts_for(row.mod_id) if self.m.profiles.is_enabled(row.mod_id) else []
row.warn.set_visible(bool(confs))
if confs:
others = sorted({self.m.library.get(i).name for c in confs for i in c.mods
if i != row.mod_id and self.m.library.get(i)})
row.warn.set_tooltip_text(f"Conflicts with: {', '.join(others)}")
def _update_all_warns(self) -> None:
for row in self.rows.values():
self._update_warn(row)
# -- filtering -----------------------------------------------------------------------------
def _filter(self, row) -> bool:
mod = self.m.library.get(row.mod_id)
if mod is None:
return False
if self._category and mod.category != self._category:
return False
q = self._search
return not q or q in mod.name.lower() or q in mod.author.lower() or q in mod.category.lower()
def _on_search(self, entry) -> None:
self._search = entry.get_text().strip().lower()
self.listbox.invalidate_filter()
def _on_category_selected(self, dd, _p) -> None:
if self._syncing_categories:
return
item = dd.get_selected_item()
cat = item.get_string() if item else ""
self._category = "" if cat == "All categories" else cat
self.listbox.invalidate_filter()
# -- selection / edits ------------------------------------------------------------------------
def _on_row_selected(self, _lb, row) -> None:
if row is None:
return
mod = self.m.library.get(row.mod_id)
if mod:
self.details.show_mod(mod)
self.split.set_show_content(True)
def _on_mod_edited(self, mod_id: str) -> None:
self._fill_categories()
row = self.rows.get(mod_id)
mod = self.m.library.get(mod_id)
if row and mod:
self._fill_list()
self.listbox.invalidate_filter()
def _selected_id(self) -> str | None:
row = self.listbox.get_selected_row()
return row.mod_id if row else None
# -- enabling / ordering -------------------------------------------------------------------------
def _after_change(self, res: ApplyResult | None) -> None:
self._recompute()
self._update_all_warns()
mid = self.details.mod_id
if mid and self.m.library.get(mid):
self.details.show_mod(self.m.library.get(mid))
if res is not None:
for w in res.warnings[:3]:
self.toast(w, 8)
def _on_toggled(self, check, mod_id: str) -> None:
on = check.get_active()
try:
res = self.m.set_enabled(mod_id, on)
except (DeployError, OSError) as e:
self.m.profiles.set_enabled(mod_id, not on)
self.m.profiles.save()
check.handler_block_by_func(self._on_toggled)
check.set_active(not on)
check.handler_unblock_by_func(self._on_toggled)
self.toast(f"Deploy failed: {e}", 10)
return
name = self.m.library.get(mod_id).name
if res is not None and (res.added or res.removed):
self.toast(f"{'Enabled' if on else 'Disabled'} {name}")
self._after_change(res)
def _move_selected(self, delta: int) -> None:
mid = self._selected_id()
if not mid:
return
order = self.m.profiles.order()
idx = order.index(mid)
new = idx + delta
if 0 <= new < len(order):
self._reorder(mid, new)
def _reorder(self, mod_id: str, new_index: int) -> None:
try:
res = self.m.move(mod_id, new_index)
except (DeployError, OSError) as e:
self.toast(f"Reorder failed: {e}", 10)
return
self._fill_list()
self._after_change(res)
def _on_row_drop(self, value, target_id: str) -> bool:
order = self.m.profiles.order()
if not isinstance(value, str) or value == target_id or value not in order or target_id not in order:
return False
self._reorder(value, order.index(target_id))
return True
# -- profiles ------------------------------------------------------------------------------------------
def _on_profile_selected(self, dd, _p) -> None:
if self._syncing_profiles:
return
item = dd.get_selected_item()
if not item or item.get_string() == self.m.profiles.active:
return
try:
self.m.switch_profile(item.get_string())
except (DeployError, OSError) as e:
self.toast(f"Could not switch profile: {e}", 10)
self.refresh_all()
self.toast(f"Profile: {self.m.profiles.active}")
def _new_profile(self) -> None:
def make(name: str):
try:
self.m.profiles.create(name, copy_from=self.m.profiles.active)
self.m.profiles.save()
self.m.switch_profile(name)
except Exception as e:
self.toast(str(e))
self.refresh_all()
prompt_text(self, "New profile", "", "Create", make)
def _rename_profile(self) -> None:
def rename(name: str):
try:
self.m.profiles.rename(self.m.profiles.active, name)
self.m.profiles.save()
except Exception as e:
self.toast(str(e))
self._fill_profiles()
prompt_text(self, "Rename profile", self.m.profiles.active, "Rename", rename)
def _delete_profile(self) -> None:
name = self.m.profiles.active
def go():
try:
self.m.profiles.delete(name)
self.m.profiles.save()
self.m.switch_profile(self.m.profiles.active)
except Exception as e:
self.toast(str(e))
self.refresh_all()
if len(self.m.profiles.names) <= 1:
self.toast("Cannot delete the last profile")
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 ---------------------------------------------------------------------------------------------
def _check_updates_all(self) -> None:
def work():
from ..core.nexus import KeyStore, NexusClient, NxmLink
client = NexusClient(KeyStore(self.m.paths.config_dir / "nexus.json").get())
updates = []
for mod in self.m.library.mods.values():
if mod.nexus_mod_id:
try:
info = client.mod_info(NxmLink(game="monsterhunterwilds", mod_id=int(mod.nexus_mod_id), file_id=0))
if info.get("version") and info["version"] != mod.version:
updates.append(mod.name)
except Exception:
pass
return updates
def done(updates, err):
if err:
self.toast(f"Update check failed: {err}")
elif updates:
self.toast("Updates available for: " + ", ".join(updates), 10)
else:
self.toast("All Nexus mods are up to date.")
self.toast("Checking for updates...")
from .tasks import run_async
run_async(work, done)
def _undeploy_all(self) -> None:
def go():
try:
res = self.m.undeploy_all()
except (DeployError, OSError) as e:
self.toast(f"Undeploy failed: {e}", 10)
return
self.toast(f"Removed {res.removed} managed file(s); profile selection is unchanged")
self._after_change(res)
confirm(self, "Undeploy all mods?",
"Every file the manager placed in the game folder is removed and any files it backed up are restored. "
"Your library and profiles are kept.", "Undeploy", go, destructive=True)
def _open_setup(self) -> None:
GameSetupWindow(self, self.m, self.refresh_all).present()
def _open_prefs(self) -> None:
PreferencesWindow(self, self.m, self.keystore, self._prefs_changed).present()
def _prefs_changed(self) -> None:
if self.m.settings.game_dir and (not self.m.game_dir or str(self.m.game_dir) != self.m.settings.game_dir):
self._startup()
def _choose_game_folder(self) -> None:
d = Gtk.FileDialog(title="Select the MonsterHunterWilds folder")
def done(dlg, res):
try:
f = dlg.select_folder_finish(res)
except GLib.Error:
return
path = Path(f.get_path())
if not (path / "MonsterHunterWilds.exe").exists():
self.toast("That folder does not contain MonsterHunterWilds.exe")
return
self.m.set_game_dir(str(path))
self._startup()
d.select_folder(self, None, done)
def _show_conflicts(self) -> None:
confs = self.m.conflicts()
name = lambda i: self.m.library.get(i).name if self.m.library.get(i) else i
kinds = {"loose": "Loose file", "pak-pak": "PAK vs PAK", "pak-loose": "PAK vs loose file"}
text = "\n\n".join(f"{kinds[c.kind]}: {c.key}\n winner: {name(c.winner)}\n overridden: "
f"{', '.join(name(i) for i in c.losers)}" for c in confs) \
or "No conflicts between enabled mods."
TextDialog(self, f"Conflicts ({len(confs)})", text).present()
# -- import ---------------------------------------------------------------------------------------------------
def _import_dialog(self) -> None:
d = Gtk.FileDialog(title="Import mod archives")
flt = Gtk.FileFilter()
flt.set_name("Mod archives")
for p in ARCHIVE_PATTERNS:
flt.add_pattern(p)
allf = Gtk.FileFilter()
allf.set_name("All files")
allf.add_pattern("*")
filters = Gio.ListStore.new(Gtk.FileFilter)
filters.append(flt)
filters.append(allf)
d.set_filters(filters)
d.set_default_filter(flt)
def done(dlg, res):
try:
files = dlg.open_multiple_finish(res)
except GLib.Error:
return
self.import_paths([Path(files.get_item(i).get_path()) for i in range(files.get_n_items())])
d.open_multiple(self, None, done)
def _on_files_dropped(self, _t, value, _x, _y) -> bool:
paths = [Path(f.get_path()) for f in value.get_files() if f.get_path()]
if not paths:
return False
self.import_paths(paths)
return True
def import_paths(self, paths: list[Path]) -> None:
if not self.m.deployer:
self.toast("Set up the game folder first")
return
self._busy += 1
self.toast(f"Importing {len(paths)} item(s)…", 2)
def work():
mods, errors = [], []
for p in paths:
try:
mods.append(self.m.import_source(p))
except (LibraryError, OSError) as e:
errors.append(f"{p.name}: {e}")
return mods, errors
def done(result, err):
self._busy -= 1
if err:
self.toast(f"Import failed: {err}", 10)
return
mods, errors = result
self.refresh_all()
if mods:
self.toast(f"Imported {', '.join(m.name for m in mods[:3])}" + (" …" if len(mods) > 3 else ""))
self.listbox.select_row(self.rows[mods[-1].id])
for e in errors[:3]:
self.toast(e, 10)
run_async(work, done)
# -- dll decision / delete -----------------------------------------------------------------------------------------
def _choose_dll(self, mod_id: str) -> None:
def chosen(rid: str):
if rid == "cancel":
return
self.m.update_mod(mod_id, dll_choice=rid)
try:
res = self.m.deploy() if self.m.profiles.is_enabled(mod_id) and self.m.settings.auto_deploy else None
except (DeployError, OSError) as e:
self.toast(f"Deploy failed: {e}", 10)
res = None
self._after_change(res)
alert(self, "What is this .dll?",
"A .dll at the top level of a mod could be a REFramework plugin (goes in reframework/plugins) "
"or a game-root DLL such as a proxy loader. The manager will not guess.",
[("cancel", "Cancel", None), ("skip", "Ignore it", None), ("root", "Game root", None),
("plugin", "REFramework plugin", None)], chosen)
def _delete_mod(self, mod_id: str) -> None:
mod = self.m.library.get(mod_id)
if not mod:
return
def go():
try:
self.m.delete_mod(mod_id)
except (DeployError, OSError, LibraryError) as e:
self.toast(f"Could not delete: {e}", 10)
return
self.refresh_all()
self.toast(f"Deleted {mod.name}")
confirm(self, f"Delete “{mod.name}”?", "It is undeployed and removed from the library, including its files.",
"Delete", go, destructive=True)
# -- nxm:// --------------------------------------------------------------------------------------------------------------
def handle_nxm(self, url: str) -> None:
try:
link = nexus.parse_nxm(url)
except nexus.NexusError as e:
self.toast(str(e), 8)
return
key = self.keystore.get()
if not key:
self.toast("Set your Nexus API key in Preferences first", 8)
self._open_prefs()
return
if not self.m.deployer:
self.toast("Set up the game folder first")
return
client = nexus.NexusClient(key)
self.toast(f"Downloading mod {link.mod_id} from Nexus…", 3)
self._busy += 1
dl_id = f"{link.mod_id}-{link.file_id}"
dl_row = self.dl_manager.add_download(dl_id, f"Mod {link.mod_id}")
def work():
info = {}
try:
info = nexus.prefill(link, client.mod_info(link))
except nexus.NexusError:
info = nexus.prefill(link, {}) # metadata is a nicety; the download is what matters
if info.get("name"):
GLib.idle_add(dl_row.label.set_text, info["name"])
links = client.download_links(link)
if not links:
raise nexus.NexusError("Nexus returned no download servers")
def progress_cb(downloaded, total):
GLib.idle_add(dl_row.update_progress, downloaded, total)
archive = nexus.download(links[0]["URI"], self.m.paths.downloads_dir, progress=progress_cb)
GLib.idle_add(dl_row.set_status, "Importing...")
mod = self.m.import_source(archive, info)
pic = info.get("picture_url")
if pic and not mod.screenshot:
dest = mod.files_root / "nexus-thumbnail.jpg"
if nexus.fetch_thumbnail(pic, dest):
self.m.update_mod(mod.id, screenshot="nexus-thumbnail.jpg")
return mod, client.limits.summary()
def done(result, err):
self._busy -= 1
if err:
dl_row.set_status("Failed")
err_str = GLib.markup_escape_text(str(err))
self.toast(f"Nexus download failed: {err_str}", 10)
return
dl_row.set_status("Complete")
mod, quota = result
self.refresh_all()
self.toast(f"Imported {mod.name}" + (f" · {quota}" if quota else ""), 8)
if mod.id in self.rows:
self.listbox.select_row(self.rows[mod.id])
run_async(work, done)