dao-modman 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dao_modman/__init__.py +0 -0
- dao_modman/__main__.py +252 -0
- dao_modman/assets/presets.toml +28 -0
- dao_modman/core/__init__.py +0 -0
- dao_modman/core/addins.py +120 -0
- dao_modman/core/archive.py +129 -0
- dao_modman/core/chargen.py +186 -0
- dao_modman/core/config.py +100 -0
- dao_modman/core/console.py +126 -0
- dao_modman/core/dazip.py +75 -0
- dao_modman/core/deploy.py +543 -0
- dao_modman/core/erf.py +83 -0
- dao_modman/core/gff.py +174 -0
- dao_modman/core/ignore.py +64 -0
- dao_modman/core/install.py +97 -0
- dao_modman/core/ipc.py +119 -0
- dao_modman/core/itemlists.py +175 -0
- dao_modman/core/laa.py +102 -0
- dao_modman/core/launch.py +33 -0
- dao_modman/core/library.py +338 -0
- dao_modman/core/metadata.py +176 -0
- dao_modman/core/models.py +112 -0
- dao_modman/core/moditems.py +205 -0
- dao_modman/core/nexus.py +244 -0
- dao_modman/core/overridepkg.py +192 -0
- dao_modman/core/pathops.py +151 -0
- dao_modman/core/paths.py +151 -0
- dao_modman/core/presets.py +109 -0
- dao_modman/core/report.py +210 -0
- dao_modman/core/resources.py +79 -0
- dao_modman/core/revisions.py +69 -0
- dao_modman/core/terminal.py +61 -0
- dao_modman/core/textures.py +148 -0
- dao_modman/tui/__init__.py +0 -0
- dao_modman/tui/app.py +1049 -0
- dao_modman/tui/app.tcss +126 -0
- dao_modman/tui/screens.py +748 -0
- dao_modman-0.1.0.dist-info/METADATA +161 -0
- dao_modman-0.1.0.dist-info/RECORD +42 -0
- dao_modman-0.1.0.dist-info/WHEEL +4 -0
- dao_modman-0.1.0.dist-info/entry_points.txt +2 -0
- dao_modman-0.1.0.dist-info/licenses/LICENSE +21 -0
dao_modman/__init__.py
ADDED
|
File without changes
|
dao_modman/__main__.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Command line entry point: the TUI by default, plus `nxm://` link handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .core import console, deploy, ipc, laa, nexus, paths, report, revisions, terminal
|
|
13
|
+
from .core.config import TOKEN_VAR, Config, load_config, load_token
|
|
14
|
+
from .core.library import Library
|
|
15
|
+
from .core.nexus import NexusError
|
|
16
|
+
|
|
17
|
+
DESKTOP_FILE = "dao-modman-nxm.desktop"
|
|
18
|
+
DESKTOP_ENTRY = """[Desktop Entry]
|
|
19
|
+
Type=Application
|
|
20
|
+
Name=dao-modman (nxm links)
|
|
21
|
+
Exec="{python}" -m dao_modman nxm %u
|
|
22
|
+
Terminal=false
|
|
23
|
+
NoDisplay=true
|
|
24
|
+
MimeType=x-scheme-handler/nxm;
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _report(message: str) -> None:
|
|
29
|
+
"""The handler has no terminal of its own, so errors also go to the desktop."""
|
|
30
|
+
print(f"error: {message}", file=sys.stderr)
|
|
31
|
+
if shutil.which("notify-send"):
|
|
32
|
+
subprocess.run(["notify-send", "--app-name=dao-modman", "dao-modman", message], check=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _forward(config: Config, url: str) -> bool:
|
|
36
|
+
"""Hand the link to the running instance, if there is one."""
|
|
37
|
+
if config.runtime_dir is None or not ipc.is_running(config.runtime_dir):
|
|
38
|
+
return False
|
|
39
|
+
ipc.send_link(config.runtime_dir, url)
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_nxm(url: str) -> int:
|
|
44
|
+
"""Pass a link to the running instance, or start one in a new terminal window."""
|
|
45
|
+
config = load_config()
|
|
46
|
+
try:
|
|
47
|
+
nexus.parse_nxm(url)
|
|
48
|
+
if not _forward(config, url):
|
|
49
|
+
argv = [sys.executable, "-m", "dao_modman", "--nxm", url]
|
|
50
|
+
subprocess.Popen(
|
|
51
|
+
terminal.command(config.terminal, argv),
|
|
52
|
+
start_new_session=True,
|
|
53
|
+
stdin=subprocess.DEVNULL,
|
|
54
|
+
stdout=subprocess.DEVNULL,
|
|
55
|
+
stderr=subprocess.DEVNULL,
|
|
56
|
+
)
|
|
57
|
+
except (NexusError, ipc.IpcError, terminal.TerminalError, OSError) as exc:
|
|
58
|
+
_report(str(exc))
|
|
59
|
+
return 1
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_install_nxm_handler() -> int:
|
|
64
|
+
applications = Path.home() / ".local/share/applications"
|
|
65
|
+
applications.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
desktop = applications / DESKTOP_FILE
|
|
67
|
+
desktop.write_text(DESKTOP_ENTRY.format(python=sys.executable), encoding="utf-8")
|
|
68
|
+
print(f"wrote {desktop}")
|
|
69
|
+
if not shutil.which("xdg-mime"):
|
|
70
|
+
print("xdg-mime not found: register the nxm:// handler manually", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
# On KDE, xdg-mime calls qtpaths, which some distros keep outside PATH.
|
|
73
|
+
env = {**os.environ, "PATH": f"{os.environ.get('PATH', '')}:/usr/lib/qt6/bin"}
|
|
74
|
+
command = ["xdg-mime", "default", DESKTOP_FILE, "x-scheme-handler/nxm"]
|
|
75
|
+
subprocess.run(command, check=True, env=env)
|
|
76
|
+
print("registered as handler for nxm:// links")
|
|
77
|
+
# The browser starts the handler outside the project dir, where ./.env is not found.
|
|
78
|
+
config_dir = paths.config_dir()
|
|
79
|
+
if not os.environ.get(TOKEN_VAR) and not load_token(config_dir, cwd=config_dir):
|
|
80
|
+
print(
|
|
81
|
+
f"note: the handler will not find your API token: put {TOKEN_VAR}=... "
|
|
82
|
+
f"into {config_dir / '.env'}"
|
|
83
|
+
)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _library(config: Config) -> Library:
|
|
88
|
+
return Library(config.dirs, config.ignore)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_laa(action: str, exe: Path | None) -> int:
|
|
92
|
+
install_dir = load_config().game_install_dir
|
|
93
|
+
if not install_dir:
|
|
94
|
+
print(
|
|
95
|
+
"error: game install dir not found: set game_install_dir in config.toml",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
return 1
|
|
99
|
+
try:
|
|
100
|
+
if action == "patch":
|
|
101
|
+
if exe is None:
|
|
102
|
+
print(
|
|
103
|
+
"error: pass the LAA-patched executable with --exe PATH; how to get one:\n"
|
|
104
|
+
f" {laa.GUIDE_URL}",
|
|
105
|
+
file=sys.stderr,
|
|
106
|
+
)
|
|
107
|
+
return 1
|
|
108
|
+
backup = laa.patch(install_dir, exe)
|
|
109
|
+
print(f"patched {laa.game_exe(install_dir)}\noriginal saved as {backup}")
|
|
110
|
+
elif action == "restore":
|
|
111
|
+
print(f"restored {laa.restore(install_dir)}")
|
|
112
|
+
else:
|
|
113
|
+
print(f"{laa.game_exe(install_dir)}: {laa.status(install_dir)}")
|
|
114
|
+
except laa.LaaError as exc:
|
|
115
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_console(action: str) -> int:
|
|
121
|
+
config = load_config()
|
|
122
|
+
if not config.game_install_dir:
|
|
123
|
+
print(
|
|
124
|
+
"error: game install dir not found: set game_install_dir in config.toml",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
return 1
|
|
128
|
+
try:
|
|
129
|
+
if action == "enable":
|
|
130
|
+
console.enable(config.game_install_dir)
|
|
131
|
+
key = console.ensure_key(config.game_user_dir) if config.game_user_dir else None
|
|
132
|
+
print(f"console enabled, open it with: {console.key_label(key)}")
|
|
133
|
+
if not console.fonts_installed(config.game_user_dir, _library(config)):
|
|
134
|
+
print(f"console text is invisible without fonts.erf: {console.FONTS_MOD_URL}")
|
|
135
|
+
elif action == "disable":
|
|
136
|
+
console.disable(config.game_install_dir)
|
|
137
|
+
print("console disabled")
|
|
138
|
+
else:
|
|
139
|
+
state = "enabled" if console.is_enabled(config.game_install_dir) else "disabled"
|
|
140
|
+
print(f"console: {state}")
|
|
141
|
+
except console.ConsoleError as exc:
|
|
142
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_conflicts(action: str | None, names: list[str], accept_all: bool) -> int:
|
|
148
|
+
library = _library(load_config())
|
|
149
|
+
plan = deploy.build_plan(library)
|
|
150
|
+
if action is None:
|
|
151
|
+
print(report.format_conflicts(library, plan) or "no conflicts")
|
|
152
|
+
return 1 if plan.with_status(deploy.ERROR) else 0
|
|
153
|
+
|
|
154
|
+
if bool(names) == accept_all:
|
|
155
|
+
print("error: give file names or mod ids to accept, or --all (not both)", file=sys.stderr)
|
|
156
|
+
return 2
|
|
157
|
+
selected, unmatched = report.select_conflicts(plan, names)
|
|
158
|
+
if unmatched:
|
|
159
|
+
print(f"error: no open conflict matches {', '.join(unmatched)}", file=sys.stderr)
|
|
160
|
+
return 1
|
|
161
|
+
for conflict in selected:
|
|
162
|
+
*losers, winner = conflict.mod_ids
|
|
163
|
+
library.accept_overwrite(winner, losers, conflict.name)
|
|
164
|
+
print(f"accepted {conflict.name}: {winner} overwrites {', '.join(losers)}")
|
|
165
|
+
if not selected:
|
|
166
|
+
print("no open conflicts")
|
|
167
|
+
for conflict in plan.with_status(deploy.ERROR):
|
|
168
|
+
print(f"not accepted: {report.file_line(library, conflict)}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_chargen() -> int:
|
|
173
|
+
config = load_config()
|
|
174
|
+
result, text = report.chargen_report(config.game_install_dir, _library(config))
|
|
175
|
+
print(text)
|
|
176
|
+
return 1 if result and result.dangling else 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_revisions() -> int:
|
|
180
|
+
data_dir = load_config().dirs.data
|
|
181
|
+
if not revisions.init(data_dir):
|
|
182
|
+
print("error: could not create a git repository (is git installed?)", file=sys.stderr)
|
|
183
|
+
return 1
|
|
184
|
+
print(f"tracking the library's metadata in {data_dir}")
|
|
185
|
+
print("every change is now committed there; look at it with:")
|
|
186
|
+
print(f" git -C '{data_dir}' log --stat")
|
|
187
|
+
print(f"to stop, delete {data_dir / '.git'}")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
parser = argparse.ArgumentParser(prog="dao-modman", description=__doc__)
|
|
193
|
+
commands = parser.add_subparsers(dest="command")
|
|
194
|
+
parser.add_argument("--nxm", metavar="URL", help="start the TUI and download this link")
|
|
195
|
+
nxm = commands.add_parser("nxm", help="hand a 'Mod Manager Download' link to the TUI")
|
|
196
|
+
nxm.add_argument("url")
|
|
197
|
+
commands.add_parser("install-nxm-handler", help="register dao-modman for nxm:// links")
|
|
198
|
+
laa_cmd = commands.add_parser("laa", help="Large Address Aware patch for the game exe")
|
|
199
|
+
laa_cmd.add_argument("action", choices=["status", "patch", "restore"])
|
|
200
|
+
laa_cmd.add_argument("--exe", type=Path, help="LAA-patched executable to install (for patch)")
|
|
201
|
+
console_cmd = commands.add_parser("console", help="enable the game's developer console")
|
|
202
|
+
console_cmd.add_argument("action", choices=["status", "enable", "disable"])
|
|
203
|
+
conflicts = commands.add_parser(
|
|
204
|
+
"conflicts", help="report files that several mods ship, or accept them as intended"
|
|
205
|
+
)
|
|
206
|
+
conflicts.add_argument("action", nargs="?", choices=["accept"])
|
|
207
|
+
conflicts.add_argument(
|
|
208
|
+
"names", nargs="*", help="with accept: file names or mod ids of the conflicts to accept"
|
|
209
|
+
)
|
|
210
|
+
conflicts.add_argument("--all", action="store_true", help="with accept: every open conflict")
|
|
211
|
+
revisions_cmd = commands.add_parser(
|
|
212
|
+
"revisions", help="keep a history of the library's metadata in git (debugging aid)"
|
|
213
|
+
)
|
|
214
|
+
revisions_cmd.add_argument("action", choices=["init"])
|
|
215
|
+
commands.add_parser(
|
|
216
|
+
"chargen", help="report on the merged character creator list and its broken entries"
|
|
217
|
+
)
|
|
218
|
+
args = parser.parse_args(argv)
|
|
219
|
+
|
|
220
|
+
if args.command == "nxm":
|
|
221
|
+
return cmd_nxm(args.url)
|
|
222
|
+
if args.command == "chargen":
|
|
223
|
+
return cmd_chargen()
|
|
224
|
+
if args.command == "revisions":
|
|
225
|
+
return cmd_revisions()
|
|
226
|
+
if args.command == "conflicts":
|
|
227
|
+
return cmd_conflicts(args.action, args.names, args.all)
|
|
228
|
+
if args.command == "console":
|
|
229
|
+
return cmd_console(args.action)
|
|
230
|
+
if args.command == "laa":
|
|
231
|
+
return cmd_laa(args.action, args.exe)
|
|
232
|
+
if args.command == "install-nxm-handler":
|
|
233
|
+
return cmd_install_nxm_handler()
|
|
234
|
+
|
|
235
|
+
config = load_config()
|
|
236
|
+
if args.nxm:
|
|
237
|
+
# Lost the race against another instance that is starting: let that one handle it.
|
|
238
|
+
try:
|
|
239
|
+
if _forward(config, args.nxm):
|
|
240
|
+
return 0
|
|
241
|
+
except ipc.IpcError as exc:
|
|
242
|
+
_report(str(exc))
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
from .tui.app import ModManApp
|
|
246
|
+
|
|
247
|
+
ModManApp(config, links=[args.nxm] if args.nxm else []).run()
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Predefined path operations for mods that only work when installed at a specific place.
|
|
2
|
+
#
|
|
3
|
+
# An entry is identified by the Nexus mod id and file id. Operations have the same form as
|
|
4
|
+
# in a mod's mod.toml; {mod} stands for the mod's own override folder
|
|
5
|
+
# (packages/core/override/<mod-id>), which differs from one library to the next.
|
|
6
|
+
#
|
|
7
|
+
# The same file in ~/.config/dao-modman/ adds entries, and wins over an entry here for the
|
|
8
|
+
# same mod and file.
|
|
9
|
+
|
|
10
|
+
[[preset]]
|
|
11
|
+
mod_id = 1825
|
|
12
|
+
file_id = 9280
|
|
13
|
+
description = "The bundled exe has hardcoded paths. For the mod to work, the exe needs to be placed in override/siramods_items"
|
|
14
|
+
|
|
15
|
+
[[preset.ops]]
|
|
16
|
+
op = "rename"
|
|
17
|
+
from = "{mod}/ADD_ITEM_DELUXE_1_1/siramods_items"
|
|
18
|
+
to = "packages/core/override/siramods_items"
|
|
19
|
+
|
|
20
|
+
[[preset]]
|
|
21
|
+
mod_id = 1110
|
|
22
|
+
file_id = 5571
|
|
23
|
+
description = "The game only reads fonts.erf from packages/core/data, not from the override folder"
|
|
24
|
+
|
|
25
|
+
[[preset.ops]]
|
|
26
|
+
op = "rename"
|
|
27
|
+
from = "{mod}/fonts.erf"
|
|
28
|
+
to = "packages/core/data/fonts.erf"
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Register / unregister dazip AddIns in the game's `Settings/AddIns.xml`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .dazip import AddIn, addin_from_element
|
|
10
|
+
from .metadata import write_atomic
|
|
11
|
+
from .paths import resolve_case
|
|
12
|
+
|
|
13
|
+
ADDINS_REL = "Settings/AddIns.xml"
|
|
14
|
+
SHIPPED_REL = "Addins" # in the game's install dir
|
|
15
|
+
|
|
16
|
+
MANAGED = "managed" # registered by one of our mods
|
|
17
|
+
OFFICIAL = "official" # the game's own DLC
|
|
18
|
+
EXTERNAL = "external" # installed by hand or by another tool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AddInsError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def list_registered(user_dir: Path) -> list[AddIn]:
|
|
26
|
+
"""Everything registered in the game's `Settings/addins.xml`."""
|
|
27
|
+
addins_xml = resolve_case(user_dir, ADDINS_REL)
|
|
28
|
+
if not addins_xml.is_file():
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
root = _load(addins_xml)
|
|
32
|
+
except (ET.ParseError, OSError, ValueError) as exc:
|
|
33
|
+
raise AddInsError(f"cannot read {addins_xml}: {exc}") from exc
|
|
34
|
+
return [addin for item in root.findall("AddInItem") if (addin := addin_from_element(item))]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def shipped_uids(install_dir: Path | None) -> set[str]:
|
|
38
|
+
"""Case-folded UIDs of the AddIns that ship in the game's install dir."""
|
|
39
|
+
if install_dir is None:
|
|
40
|
+
return set()
|
|
41
|
+
shipped = resolve_case(install_dir, SHIPPED_REL)
|
|
42
|
+
if not shipped.is_dir():
|
|
43
|
+
return set()
|
|
44
|
+
return {entry.name.casefold() for entry in shipped.iterdir() if entry.is_dir()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify(addin: AddIn, managed: set[str], shipped: set[str]) -> str:
|
|
48
|
+
"""Tell our mods, official DLC and foreign AddIns apart.
|
|
49
|
+
|
|
50
|
+
Being shipped in the install dir is the reliable sign of official content. The
|
|
51
|
+
`BioWare="1"` attribute is only self-declared, but it is all there is for DLC that was
|
|
52
|
+
downloaded into the user dir (non-Ultimate editions). Our own mods are ruled out first,
|
|
53
|
+
so a mod claiming to be BioWare content is not mistaken for DLC.
|
|
54
|
+
"""
|
|
55
|
+
if addin.uid.casefold() in {uid.casefold() for uid in managed}:
|
|
56
|
+
return MANAGED
|
|
57
|
+
if addin.uid.casefold() in shipped or addin.bioware:
|
|
58
|
+
return OFFICIAL
|
|
59
|
+
return EXTERNAL
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load(path: Path) -> ET.Element:
|
|
63
|
+
if path.is_file():
|
|
64
|
+
root = ET.parse(path).getroot()
|
|
65
|
+
if root.tag != "AddInsList":
|
|
66
|
+
raise ValueError(f"{path}: unexpected root element <{root.tag}>")
|
|
67
|
+
return root
|
|
68
|
+
return ET.Element("AddInsList")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _save(path: Path, root: ET.Element) -> None:
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
ET.indent(root)
|
|
74
|
+
write_atomic(path, ET.tostring(root, encoding="utf-8", xml_declaration=True))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def absent(addins_xml: Path, addins: list[AddIn]) -> list[str]:
|
|
78
|
+
"""UIDs of `addins` that `register` would add, i.e. that are not registered yet."""
|
|
79
|
+
present = {(item.get("UID") or "").casefold() for item in _load(addins_xml).iter("AddInItem")}
|
|
80
|
+
missing: list[str] = []
|
|
81
|
+
for addin in addins:
|
|
82
|
+
if addin.uid.casefold() not in present:
|
|
83
|
+
present.add(addin.uid.casefold())
|
|
84
|
+
missing.append(addin.uid)
|
|
85
|
+
return missing
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def register(addins_xml: Path, addins: list[AddIn]) -> list[str]:
|
|
89
|
+
"""Add missing AddIns; returns the UIDs that were actually added."""
|
|
90
|
+
if not addins:
|
|
91
|
+
return []
|
|
92
|
+
root = _load(addins_xml)
|
|
93
|
+
# The game treats UIDs case-insensitively: "MyAddIn" and "myaddin" are the same AddIn.
|
|
94
|
+
present = {(item.get("UID") or "").casefold() for item in root.iter("AddInItem")}
|
|
95
|
+
added = []
|
|
96
|
+
for addin in addins:
|
|
97
|
+
if addin.uid.casefold() in present:
|
|
98
|
+
continue
|
|
99
|
+
root.append(copy.deepcopy(addin.element))
|
|
100
|
+
present.add(addin.uid.casefold())
|
|
101
|
+
added.append(addin.uid)
|
|
102
|
+
if added:
|
|
103
|
+
_save(addins_xml, root)
|
|
104
|
+
return added
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def unregister(addins_xml: Path, uids: list[str]) -> list[str]:
|
|
108
|
+
"""Remove AddIns by UID; returns the UIDs that were removed."""
|
|
109
|
+
if not uids or not addins_xml.is_file():
|
|
110
|
+
return []
|
|
111
|
+
root = _load(addins_xml)
|
|
112
|
+
removed = []
|
|
113
|
+
wanted = {uid.casefold() for uid in uids}
|
|
114
|
+
for item in list(root.findall("AddInItem")):
|
|
115
|
+
if (item.get("UID") or "").casefold() in wanted:
|
|
116
|
+
root.remove(item)
|
|
117
|
+
removed.append(item.get("UID"))
|
|
118
|
+
if removed:
|
|
119
|
+
_save(addins_xml, root)
|
|
120
|
+
return removed
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Hashing and safe extraction of mod archives (zip/dazip/override, 7z, rar)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import py7zr
|
|
13
|
+
|
|
14
|
+
from .paths import normalize_rel
|
|
15
|
+
|
|
16
|
+
_CHUNK = 1024 * 1024
|
|
17
|
+
_SEVENZIP_MAGIC = b"7z\xbc\xaf\x27\x1c"
|
|
18
|
+
_RAR_MAGIC = b"Rar!\x1a\x07"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ArchiveError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def file_hashes(path: Path) -> tuple[str, str]:
|
|
26
|
+
"""Return (sha256, md5) of a file."""
|
|
27
|
+
sha256, md5 = hashlib.sha256(), hashlib.md5(usedforsecurity=False)
|
|
28
|
+
with path.open("rb") as fh:
|
|
29
|
+
while chunk := fh.read(_CHUNK):
|
|
30
|
+
sha256.update(chunk)
|
|
31
|
+
md5.update(chunk)
|
|
32
|
+
return sha256.hexdigest(), md5.hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sha256_file(path: Path) -> str:
|
|
36
|
+
return file_hashes(path)[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_name(name: str) -> str:
|
|
40
|
+
try:
|
|
41
|
+
if name.startswith(("/", "\\")):
|
|
42
|
+
raise ValueError("absolute path")
|
|
43
|
+
return normalize_rel(name)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise ArchiveError(f"unsafe path in archive: {name!r}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_zip(archive: Path, dest: Path) -> None:
|
|
49
|
+
with zipfile.ZipFile(archive) as zf:
|
|
50
|
+
for info in zf.infolist():
|
|
51
|
+
if info.is_dir():
|
|
52
|
+
continue
|
|
53
|
+
target = dest / _safe_name(info.filename)
|
|
54
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
with zf.open(info) as src, target.open("wb") as out:
|
|
56
|
+
shutil.copyfileobj(src, out, _CHUNK)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_7z(archive: Path, dest: Path) -> None:
|
|
60
|
+
with py7zr.SevenZipFile(archive) as sz:
|
|
61
|
+
for name in sz.getnames():
|
|
62
|
+
_safe_name(name)
|
|
63
|
+
sz.extractall(dest)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_rar(archive: Path, dest: Path) -> None:
|
|
67
|
+
"""Extract with whichever tool manages: no pure-Python rar reader exists.
|
|
68
|
+
|
|
69
|
+
unrar and 7z come first because bsdtar cannot read solid archives, which are common.
|
|
70
|
+
All three refuse to write outside the target directory by default.
|
|
71
|
+
"""
|
|
72
|
+
commands = {
|
|
73
|
+
"unrar": ["unrar", "x", "-y", "-o+", str(archive), f"{dest}{os.sep}"],
|
|
74
|
+
"7z": ["7z", "x", "-y", f"-o{dest}", str(archive)],
|
|
75
|
+
"bsdtar": ["bsdtar", "-xf", str(archive), "-C", str(dest)],
|
|
76
|
+
}
|
|
77
|
+
failures = []
|
|
78
|
+
for tool, command in commands.items():
|
|
79
|
+
if not shutil.which(tool):
|
|
80
|
+
continue
|
|
81
|
+
result = subprocess.run(command, capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
82
|
+
if result.returncode == 0:
|
|
83
|
+
return
|
|
84
|
+
detail = (result.stderr or result.stdout).strip().splitlines()
|
|
85
|
+
failures.append(f"{tool}: {detail[-1] if detail else 'failed'}")
|
|
86
|
+
shutil.rmtree(dest) # whatever the failed tool left behind must not be imported
|
|
87
|
+
dest.mkdir()
|
|
88
|
+
if not failures:
|
|
89
|
+
raise ArchiveError("rar archives need one of unrar, 7z or bsdtar on PATH")
|
|
90
|
+
raise ArchiveError(f"cannot extract {archive.name} ({'; '.join(failures)})")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _drop_links(dest: Path) -> None:
|
|
94
|
+
"""Archives may carry symlinks; they must never be followed when deploying."""
|
|
95
|
+
for root, dirnames, filenames in os.walk(dest):
|
|
96
|
+
for name in dirnames + filenames:
|
|
97
|
+
path = Path(root, name)
|
|
98
|
+
if path.is_symlink():
|
|
99
|
+
path.unlink()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract(archive: Path, dest: Path) -> None:
|
|
103
|
+
"""Extract `archive` into the (new or empty) directory `dest`."""
|
|
104
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
with archive.open("rb") as fh:
|
|
106
|
+
magic = fh.read(8)
|
|
107
|
+
try:
|
|
108
|
+
if magic.startswith(_SEVENZIP_MAGIC):
|
|
109
|
+
_extract_7z(archive, dest)
|
|
110
|
+
elif magic.startswith(_RAR_MAGIC):
|
|
111
|
+
_extract_rar(archive, dest)
|
|
112
|
+
elif zipfile.is_zipfile(archive):
|
|
113
|
+
_extract_zip(archive, dest)
|
|
114
|
+
else:
|
|
115
|
+
raise ArchiveError(f"unsupported archive format: {archive.name}")
|
|
116
|
+
except (zipfile.BadZipFile, py7zr.exceptions.ArchiveError, OSError) as exc:
|
|
117
|
+
raise ArchiveError(f"cannot extract {archive.name}: {exc}") from exc
|
|
118
|
+
_drop_links(dest)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_files(root: Path) -> list[str]:
|
|
122
|
+
"""Relative posix paths of all regular files below `root`, sorted."""
|
|
123
|
+
files = []
|
|
124
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
125
|
+
for name in filenames:
|
|
126
|
+
path = Path(dirpath, name)
|
|
127
|
+
if path.is_file() and not path.is_symlink():
|
|
128
|
+
files.append(path.relative_to(root).as_posix())
|
|
129
|
+
return sorted(files)
|