Skip to content

Commit 8c76c69

Browse files
committed
Add option to install as mod
1 parent 142e13e commit 8c76c69

3 files changed

Lines changed: 199 additions & 65 deletions

File tree

src/wizards/_install_as_mod.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Shared helpers for wizards that can install their payload as a managed mod
2+
(staging folder + modlist entry + rootFolder=true flag).
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Callable
9+
10+
if TYPE_CHECKING:
11+
from Games.base_game import BaseGame
12+
13+
14+
def derive_mod_name(archive: Path, fallback: str) -> str:
15+
"""Derive a mod folder name from the archive filename, stripping the
16+
extension (including double extensions like ``.tar.gz``). Falls back to
17+
*fallback* when the resulting stem is empty.
18+
"""
19+
stem = archive.name
20+
for ext in (".tar.gz", ".tar.bz2", ".tar.xz"):
21+
if stem.lower().endswith(ext):
22+
stem = stem[: -len(ext)]
23+
break
24+
else:
25+
stem = Path(stem).stem
26+
return stem.strip() or fallback
27+
28+
29+
def register_as_mod(
30+
game: "BaseGame",
31+
mod_name: str,
32+
archive: Path,
33+
*,
34+
parent_widget,
35+
log_fn: Callable[[str], None],
36+
) -> None:
37+
"""Write meta.ini with rootFolder=true, prepend the mod to modlist.txt,
38+
and trigger a refresh of the modlist panel if reachable from *parent_widget*.
39+
40+
Must be called from the worker thread; UI refresh is scheduled via .after().
41+
"""
42+
from Nexus.nexus_meta import NexusModMeta, write_meta
43+
from Utils.modlist import prepend_mod
44+
45+
staging = game.get_effective_mod_staging_path()
46+
if staging is None:
47+
raise RuntimeError("Mod staging path is not configured.")
48+
49+
mod_dir = staging / mod_name
50+
mod_dir.mkdir(parents=True, exist_ok=True)
51+
52+
meta = NexusModMeta(
53+
mod_name=mod_name,
54+
installation_file=archive.name,
55+
root_folder=True,
56+
)
57+
write_meta(mod_dir / "meta.ini", meta)
58+
59+
mod_panel = None
60+
try:
61+
toplevel = parent_widget.winfo_toplevel()
62+
mod_panel = getattr(toplevel, "_mod_panel", None)
63+
except Exception:
64+
mod_panel = None
65+
66+
modlist_path: Path | None = None
67+
if mod_panel is not None:
68+
modlist_path = getattr(mod_panel, "_modlist_path", None)
69+
if modlist_path is None:
70+
modlist_path = game.get_profile_root() / "profiles" / "default" / "modlist.txt"
71+
72+
prepend_mod(modlist_path, mod_name, enabled=True)
73+
log_fn(f"Wizard: added '{mod_name}' to modlist with rootFolder=true.")
74+
75+
if mod_panel is not None:
76+
try:
77+
mod_panel.after(0, mod_panel.reload_after_install)
78+
except Exception as exc:
79+
log_fn(f"Wizard: could not trigger mod panel refresh: {exc}")

src/wizards/bepinex.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
TEXT_DIM, TEXT_MAIN,
3939
FONT_NORMAL, FONT_BOLD, FONT_SMALL,
4040
)
41+
from wizards._install_as_mod import derive_mod_name, register_as_mod
4142

4243
_ARCHIVE_EXTS = {".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tar.bz2", ".tar.xz"}
4344

@@ -179,7 +180,9 @@ def __init__(
179180
self._chmod_files = chmod_files or []
180181
self._archive_path: Path | None = None
181182
self._game_root: Path | None = game.get_game_path()
182-
self._install_to_root_folder = ctk.BooleanVar(value=False)
183+
# "game" = extract to game folder (default), "root" = Root_Folder staging,
184+
# "mod" = install as a managed mod (staging dir + modlist entry + rootFolder flag)
185+
self._install_mode = ctk.StringVar(value="game")
183186

184187
title_bar = ctk.CTkFrame(self, fg_color=BG_HEADER, corner_radius=0, height=40)
185188
title_bar.pack(fill="x")
@@ -206,6 +209,22 @@ def _clear_body(self):
206209
for w in self._body.winfo_children():
207210
w.destroy()
208211

212+
def _build_install_mode_chooser(self, parent) -> ctk.CTkFrame:
213+
"""Three-way radio: install to game folder, Root_Folder staging, or as a mod."""
214+
frame = ctk.CTkFrame(parent, fg_color="transparent")
215+
for label, value in (
216+
("Install to game folder", "game"),
217+
("Install to Root_Folder (staging)", "root"),
218+
("Install as mod (managed in modlist)", "mod"),
219+
):
220+
ctk.CTkRadioButton(
221+
frame, text=label,
222+
variable=self._install_mode, value=value,
223+
font=FONT_SMALL, text_color=TEXT_DIM,
224+
fg_color=ACCENT, hover_color=ACCENT_HOV,
225+
).pack(anchor="w", pady=(0, 2))
226+
return frame
227+
209228
# ------------------------------------------------------------------
210229
# Step 1 \u2014 Download prompt
211230
# ------------------------------------------------------------------
@@ -245,14 +264,7 @@ def _show_step_download(self):
245264
font=FONT_SMALL, text_color="#e06c6c",
246265
).pack(pady=(0, 8))
247266

248-
ctk.CTkCheckBox(
249-
self._body,
250-
text="Install to Root_Folder (staging) instead of game folder",
251-
variable=self._install_to_root_folder,
252-
font=FONT_SMALL, text_color=TEXT_DIM,
253-
fg_color=ACCENT, hover_color=ACCENT_HOV,
254-
checkmark_color="white",
255-
).pack(pady=(0, 12))
267+
self._build_install_mode_chooser(self._body).pack(pady=(0, 12))
256268

257269
ctk.CTkButton(
258270
self._body, text="Next \u2192", width=120, height=36,
@@ -373,38 +385,58 @@ def _show_step_extract(self):
373385

374386
def _do_extract(self):
375387
try:
376-
if self._install_to_root_folder.get():
377-
game_root = self._game.get_effective_root_folder_path()
378-
game_root.mkdir(parents=True, exist_ok=True)
379-
else:
380-
game_root = self._game_root
381-
if game_root is None:
382-
raise RuntimeError("Game path is not configured.")
383-
388+
mode = self._install_mode.get()
384389
archive = self._archive_path
385390
if archive is None or not archive.is_file():
386391
raise RuntimeError("Archive not found.")
387392

388-
self._set_status("Restoring game to vanilla state\u2026")
389-
try:
390-
self._game.restore(log_fn=self._log)
391-
except Exception as exc:
392-
self._log(f"Wizard: restore skipped or failed: {exc}")
393-
394-
self._set_status("Extracting archive to game folder\u2026")
395-
self._log(f"Wizard: extracting {archive.name} \u2192 {game_root}")
396-
397-
paths = _extract_bepinex(archive, game_root, self._inner_folder)
393+
install_as_mod = mode == "mod"
394+
mod_name: str | None = None
395+
if install_as_mod:
396+
staging = self._game.get_effective_mod_staging_path()
397+
if staging is None:
398+
raise RuntimeError("Mod staging path is not configured.")
399+
mod_name = derive_mod_name(archive, fallback="BepInEx")
400+
dest = staging / mod_name
401+
if dest.exists():
402+
shutil.rmtree(dest, ignore_errors=True)
403+
dest.mkdir(parents=True, exist_ok=True)
404+
elif mode == "root":
405+
dest = self._game.get_effective_root_folder_path()
406+
dest.mkdir(parents=True, exist_ok=True)
407+
else:
408+
dest = self._game_root
409+
if dest is None:
410+
raise RuntimeError("Game path is not configured.")
411+
self._set_status("Restoring game to vanilla state\u2026")
412+
try:
413+
self._game.restore(log_fn=self._log)
414+
except Exception as exc:
415+
self._log(f"Wizard: restore skipped or failed: {exc}")
416+
417+
dest_label = {
418+
"mod": f"mod folder ({mod_name})",
419+
"root": "Root_Folder (staging)",
420+
"game": "game folder",
421+
}[mode]
422+
self._set_status(f"Extracting archive to {dest_label}\u2026")
423+
self._log(f"Wizard: extracting {archive.name} \u2192 {dest}")
424+
425+
paths = _extract_bepinex(archive, dest, self._inner_folder)
398426
file_count = len([p for p in paths if p.is_file()])
399427
self._log(f"Wizard: extracted {file_count} file(s).")
400428

401429
for name in self._chmod_files:
402-
target = game_root / name
430+
target = dest / name
403431
if target.is_file():
404432
current = target.stat().st_mode
405433
target.chmod(current | stat.S_IXUSR)
406434
self._log(f"Wizard: chmod u+x {name}")
407435

436+
if install_as_mod:
437+
register_as_mod(self._game, mod_name, archive,
438+
parent_widget=self, log_fn=self._log)
439+
408440
try:
409441
archive.unlink()
410442
self._log(f"Wizard: deleted {archive.name} from Downloads.")
@@ -420,7 +452,6 @@ def _do_extract(self):
420452
" ./start_game_bepinex.sh %command%"
421453
)
422454

423-
dest_label = "Root_Folder (staging)" if self._install_to_root_folder.get() else "game folder"
424455
self._set_status(
425456
f"BepInEx installed successfully!\n"
426457
f"{file_count} file(s) extracted to the {dest_label}."

src/wizards/script_extender.py

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
TEXT_DIM, TEXT_MAIN,
3939
FONT_NORMAL, FONT_BOLD, FONT_SMALL,
4040
)
41+
from wizards._install_as_mod import derive_mod_name, register_as_mod
4142

4243
_ARCHIVE_EXTS = {".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tar.bz2", ".tar.xz"}
4344

@@ -207,7 +208,9 @@ def __init__(
207208
self._archive_path: Path | None = None
208209
self._resolved_download_url: str | None = None
209210
self._game_root: Path | None = game.get_game_path()
210-
self._install_to_root_folder = ctk.BooleanVar(value=False)
211+
# "game" = extract to game folder (default), "root" = Root_Folder staging,
212+
# "mod" = install as a managed mod (staging dir + modlist entry + rootFolder flag)
213+
self._install_mode = ctk.StringVar(value="game")
211214

212215
title_bar = ctk.CTkFrame(self, fg_color=BG_HEADER, corner_radius=0, height=40)
213216
title_bar.pack(fill="x")
@@ -237,6 +240,22 @@ def _clear_body(self):
237240
for w in self._body.winfo_children():
238241
w.destroy()
239242

243+
def _build_install_mode_chooser(self, parent) -> ctk.CTkFrame:
244+
"""Three-way radio: install to game folder, Root_Folder staging, or as a mod."""
245+
frame = ctk.CTkFrame(parent, fg_color="transparent")
246+
for label, value in (
247+
("Install to game folder", "game"),
248+
("Install to Root_Folder (staging)", "root"),
249+
("Install as mod (managed in modlist)", "mod"),
250+
):
251+
ctk.CTkRadioButton(
252+
frame, text=label,
253+
variable=self._install_mode, value=value,
254+
font=FONT_SMALL, text_color=TEXT_DIM,
255+
fg_color=ACCENT, hover_color=ACCENT_HOV,
256+
).pack(anchor="w", pady=(0, 2))
257+
return frame
258+
240259
# ------------------------------------------------------------------
241260
# Step 1 — Download (GitHub auto-download or manual page fallback)
242261
# ------------------------------------------------------------------
@@ -322,14 +341,7 @@ def _show_step_download_github(self):
322341
self._dl_progress.pack(pady=(0, 16))
323342
self._dl_progress.start()
324343

325-
ctk.CTkCheckBox(
326-
self._body,
327-
text="Install to Root_Folder (staging) instead of game folder",
328-
variable=self._install_to_root_folder,
329-
font=FONT_SMALL, text_color=TEXT_DIM,
330-
fg_color=ACCENT, hover_color=ACCENT_HOV,
331-
checkmark_color="white",
332-
).pack(pady=(0, 8))
344+
self._build_install_mode_chooser(self._body).pack(pady=(0, 8))
333345

334346
btn_frame = ctk.CTkFrame(self._body, fg_color="transparent")
335347
btn_frame.pack(side="bottom", pady=(8, 0))
@@ -448,14 +460,7 @@ def _show_step_download_manual(self):
448460
font=FONT_SMALL, text_color="#e06c6c",
449461
).pack(pady=(0, 8))
450462

451-
ctk.CTkCheckBox(
452-
self._body,
453-
text="Install to Root_Folder (staging) instead of game folder",
454-
variable=self._install_to_root_folder,
455-
font=FONT_SMALL, text_color=TEXT_DIM,
456-
fg_color=ACCENT, hover_color=ACCENT_HOV,
457-
checkmark_color="white",
458-
).pack(pady=(0, 12))
463+
self._build_install_mode_chooser(self._body).pack(pady=(0, 12))
459464

460465
ctk.CTkButton(
461466
self._body, text="Next \u2192", width=120, height=36,
@@ -564,38 +569,57 @@ def _show_step_extract(self):
564569

565570
def _do_extract(self):
566571
try:
567-
if self._install_to_root_folder.get():
568-
game_root = self._game.get_effective_root_folder_path()
569-
game_root.mkdir(parents=True, exist_ok=True)
570-
else:
571-
game_root = self._game_root
572-
if game_root is None:
573-
raise RuntimeError("Game path is not configured.")
574-
572+
mode = self._install_mode.get()
575573
archive = self._archive_path
576574
if archive is None or not archive.is_file():
577575
raise RuntimeError("Archive not found.")
578576

579-
self._set_status("Restoring game to vanilla state\u2026")
580-
try:
581-
self._game.restore(log_fn=self._log)
582-
except Exception as exc:
583-
self._log(f"Wizard: restore skipped or failed: {exc}")
584-
585-
self._set_status("Extracting archive to game folder\u2026")
586-
self._log(f"Wizard: extracting {archive.name} \u2192 {game_root}")
587-
588-
paths = _extract_archive(archive, game_root)
577+
install_as_mod = mode == "mod"
578+
mod_name: str | None = None
579+
if install_as_mod:
580+
staging = self._game.get_effective_mod_staging_path()
581+
if staging is None:
582+
raise RuntimeError("Mod staging path is not configured.")
583+
mod_name = derive_mod_name(archive, fallback="Script Extender")
584+
dest = staging / mod_name
585+
if dest.exists():
586+
shutil.rmtree(dest, ignore_errors=True)
587+
dest.mkdir(parents=True, exist_ok=True)
588+
elif mode == "root":
589+
dest = self._game.get_effective_root_folder_path()
590+
dest.mkdir(parents=True, exist_ok=True)
591+
else:
592+
dest = self._game_root
593+
if dest is None:
594+
raise RuntimeError("Game path is not configured.")
595+
self._set_status("Restoring game to vanilla state\u2026")
596+
try:
597+
self._game.restore(log_fn=self._log)
598+
except Exception as exc:
599+
self._log(f"Wizard: restore skipped or failed: {exc}")
600+
601+
dest_label = {
602+
"mod": f"mod folder ({mod_name})",
603+
"root": "Root_Folder (staging)",
604+
"game": "game folder",
605+
}[mode]
606+
self._set_status(f"Extracting archive to {dest_label}\u2026")
607+
self._log(f"Wizard: extracting {archive.name} \u2192 {dest}")
608+
609+
paths = _extract_archive(archive, dest)
589610
file_count = len([p for p in paths if p.is_file()])
590611
self._log(f"Wizard: extracted {file_count} file(s).")
591612

613+
if install_as_mod:
614+
register_as_mod(self._game, mod_name, archive,
615+
parent_widget=self, log_fn=self._log)
616+
592617
try:
593618
archive.unlink()
594619
self._log(f"Wizard: deleted {archive.name} from Downloads.")
595620
except OSError as exc:
596621
self._log(f"Wizard: could not delete archive: {exc}")
597622

598-
dest_label = "Root_Folder (staging)" if self._install_to_root_folder.get() else "game folder"
599623
self._set_status(
600624
f"Script extender installed successfully!\n"
601625
f"{file_count} file(s) extracted to the {dest_label}.\n\n"

0 commit comments

Comments
 (0)