Repository navigation
Expand file tree
/
Copy pathAddonsManager.py
More file actions
455 lines (374 loc) · 14.6 KB
/
Copy pathAddonsManager.py
File metadata and controls
455 lines (374 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
#!/usr/bin/env python3
"""
ESO AddOn Manager - CLI
Bridges any addon directory into the Game Pass ESO managed-addon system
using NTFS directory junctions, requiring no game file modifications.
"""
import argparse
import ctypes
import ctypes.wintypes
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
LOCAL_APPDATA = Path(os.environ["LOCALAPPDATA"])
USER_PROFILE = Path(os.environ["USERPROFILE"])
ESO_LOCAL_BASE = LOCAL_APPDATA / "Elder Scrolls Online"
DEFAULT_CHANNEL = "xb1cert"
def _get_documents_folder() -> Path:
"""Return the user's real Documents folder via SHGetFolderPathW."""
CSIDL_PERSONAL = 5
SHGFP_TYPE_CURRENT = 0
buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
ctypes.windll.shell32.SHGetFolderPathW(
None, CSIDL_PERSONAL, None, SHGFP_TYPE_CURRENT, buf
)
return Path(buf.value)
DEFAULT_SOURCE = str(_get_documents_folder() / "Elder Scrolls Online" / DEFAULT_CHANNEL / "AddOns")
REROUTER_TAG = "__addon_rerouter__" # written into our content_installation_info.json
# ---------------------------------------------------------------------------
# Admin check / elevation
# ---------------------------------------------------------------------------
def _is_admin() -> bool:
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False
def _require_admin():
if _is_admin():
return
print("Administrator privileges are required to create directory junctions.")
print("Re-launching elevated …")
args = " ".join(f'"{a}"' for a in sys.argv)
ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1)
sys.exit(0)
# ---------------------------------------------------------------------------
# NTFS junction helpers
# ---------------------------------------------------------------------------
def _is_reparse_point(path: Path) -> bool:
"""Return True if *path* is an NTFS reparse point (junction or symlink)."""
INVALID = 0xFFFFFFFF
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
if attrs == INVALID:
return False
return bool(attrs & FILE_ATTRIBUTE_REPARSE_POINT)
def _create_junction(link: Path, target: Path) -> bool:
r = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, text=True,
)
return r.returncode == 0
def _remove_junction(link: Path) -> bool:
"""Remove a junction without touching the target directory."""
r = subprocess.run(
["cmd", "/c", "rmdir", str(link)],
capture_output=True, text=True,
)
return r.returncode == 0
def _safe_remove_uuid_dir(uuid_dir: Path, addon_name: str, managed_root: Path):
"""
Remove a rerouter-created UUID directory safely.
Never uses shutil.rmtree - it follows NTFS junctions and would
delete the source addon files. os.rmdir() removes only the junction
point itself, leaving the target untouched.
"""
# Refuse to operate outside managed_root
try:
uuid_dir.resolve().relative_to(managed_root.resolve())
except ValueError:
return
junction = uuid_dir / addon_name
if _is_reparse_point(junction):
try:
os.rmdir(str(junction))
except OSError:
return # couldn't remove junction - abort rather than risk anything
elif junction.exists():
return # real directory we didn't create - leave it alone
# Remove only the specific file we wrote; then the (now-empty) dir
try:
(uuid_dir / "content_installation_info.json").unlink()
except OSError:
pass
try:
uuid_dir.rmdir()
except OSError:
pass
# ---------------------------------------------------------------------------
# UUID / mapping helpers
# ---------------------------------------------------------------------------
# We derive per-addon UUIDs deterministically from the addon name so that
# running the tool twice gives identical results and the mapping survives
# re-installation of the tool itself.
_UUID_NAMESPACE = uuid.UUID("f4ad7000-e500-4000-9000-000000000001")
def _addon_uuid(addon_name: str) -> str:
return str(uuid.uuid5(_UUID_NAMESPACE, addon_name.lower()))
def _map_path(channel: str) -> Path:
return ESO_LOCAL_BASE / channel / "CachedData" / "AddonRouterMap.json"
def _load_map(channel: str) -> dict:
p = _map_path(channel)
if p.exists():
try:
with open(p) as f:
return json.load(f)
except Exception:
pass
return {"version": 1, "managed": {}}
def _save_map(channel: str, data: dict):
p = _map_path(channel)
p.parent.mkdir(parents=True, exist_ok=True)
with open(p, "w") as f:
json.dump(data, f, indent=2)
# ---------------------------------------------------------------------------
# Addon scanning
# ---------------------------------------------------------------------------
def _scan_source(source: Path) -> dict:
"""Return {addon_name: {'path': Path, 'addon_file': Path}} for each addon folder."""
result = {}
if not source.is_dir():
return result
for entry in source.iterdir():
if not entry.is_dir():
continue
addon_files = list(entry.glob("*.addon"))
if addon_files:
result[entry.name] = {"path": entry, "addon_file": addon_files[0]}
return result
def _read_addon_version(addon_file: Path) -> str:
"""Extract ## AddOnVersion or ## Version from a .addon manifest."""
try:
for line in addon_file.read_text(encoding="utf-8", errors="ignore").splitlines():
m = re.match(r"##\s*AddOnVersion:\s*(.+)", line)
if m:
return m.group(1).strip()
m = re.match(r"##\s*Version:\s*(.+)", line)
if m:
return m.group(1).strip()
except Exception:
pass
return "custom"
# ---------------------------------------------------------------------------
# Core: create / update one managed-addon entry
# ---------------------------------------------------------------------------
def _upsert_entry(
addon_name: str,
addon_info: dict,
addon_uuid: str,
managed_root: Path,
dry_run: bool,
) -> str:
"""
Ensure AddOnsManaged/{uuid}/{AddonName} is a junction pointing at the
source addon folder. Returns 'added', 'updated', 'skipped', or 'failed'.
"""
uuid_dir = managed_root / addon_uuid
junction_path = uuid_dir / addon_name
# Determine what already exists
if junction_path.exists() or _is_reparse_point(junction_path):
if _is_reparse_point(junction_path):
action = "updated"
else:
# Real directory we didn't create - leave it alone
return "skipped"
else:
action = "added"
if dry_run:
return action
# Materialise UUID directory
uuid_dir.mkdir(parents=True, exist_ok=True)
# Write content_installation_info.json
now = str(int(time.time()))
version = _read_addon_version(addon_info["addon_file"])
info = {
"contentInstallationInfoVersion": 1,
"contentId": addon_uuid,
"installationTime": now,
"releaseVersion": version,
"releaseCreationTime": now,
"ignoreUpdates": True,
REROUTER_TAG: True,
}
with open(uuid_dir / "content_installation_info.json", "w") as f:
json.dump(info, f)
# (Re)create junction
if _is_reparse_point(junction_path):
_remove_junction(junction_path)
elif junction_path.exists():
pass # won't reach here due to earlier check
ok = _create_junction(junction_path, addon_info["path"])
return action if ok else "failed"
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_sync(source: Path, channel: str, dry_run: bool, remove_stale: bool):
managed_root = ESO_LOCAL_BASE / channel / "CachedData" / "AddOnsManaged"
print(f"Source : {source}")
print(f"Target : {managed_root}")
if dry_run:
print("(dry run - no changes will be made)")
print()
if not source.is_dir():
print(f"Source directory does not exist: {source}")
print("Create it and place addon folders inside, then run sync again.")
return
managed_root.mkdir(parents=True, exist_ok=True)
map_data = _load_map(channel)
source_addons = _scan_source(source)
print(f"Addons found in source: {len(source_addons)}")
print()
counts = {"added": 0, "updated": 0, "skipped": 0, "failed": 0}
for name, info in sorted(source_addons.items()):
a_uuid = map_data["managed"].get(name) or _addon_uuid(name)
map_data["managed"][name] = a_uuid
result = _upsert_entry(name, info, a_uuid, managed_root, dry_run)
counts[result] += 1
tag = result.upper()[:3]
print(f" [{tag}] {name}")
# Optionally remove junctions for addons no longer in source.
# Guard: if source scan returned nothing, skip stale removal entirely -
# an empty scan most likely means the source path is wrong, and treating
# all tracked addons as stale would remove all junctions unnecessarily.
if remove_stale and source_addons:
stale_names = set(map_data["managed"].keys()) - set(source_addons.keys())
for name in sorted(stale_names):
a_uuid = map_data["managed"][name]
uuid_dir = managed_root / a_uuid
info_file = uuid_dir / "content_installation_info.json"
# Only remove entries that we created
try:
with open(info_file) as f:
meta = json.load(f)
if not meta.get(REROUTER_TAG):
continue
except Exception:
continue
print(f" [REM] {name} (not in source)")
if not dry_run:
_safe_remove_uuid_dir(uuid_dir, name, managed_root)
del map_data["managed"][name]
if not dry_run:
_save_map(channel, map_data)
print()
print(
f"Complete. Added: {counts['added']} "
f"Updated: {counts['updated']} "
f"Skipped: {counts['skipped']} "
f"Failed: {counts['failed']}"
)
def cmd_status(channel: str):
managed_root = ESO_LOCAL_BASE / channel / "CachedData" / "AddOnsManaged"
plain_link = ESO_LOCAL_BASE / channel / "AddOns"
map_data = _load_map(channel)
print(f"Channel : {channel}")
print(f"Data root : {ESO_LOCAL_BASE / channel}")
print()
# Plain AddOns junction status
if plain_link.exists():
if _is_reparse_point(plain_link):
print(f"Plain AddOns\\ : junction present -> {plain_link}")
else:
print(f"Plain AddOns\\ : real directory (not a junction)")
else:
print(f"Plain AddOns\\ : not present")
print()
# Managed entries we own
owned = map_data.get("managed", {})
print(f"Rerouted addons ({len(owned)}):")
if not owned:
print(" (none)")
for name, a_uuid in sorted(owned.items()):
junction = managed_root / a_uuid / name
if _is_reparse_point(junction):
state = "OK"
elif junction.is_dir():
state = "DIR (not junction?)"
else:
state = "MISSING"
print(f" {state:20s} {name}")
def cmd_clean(channel: str, dry_run: bool):
"""Remove all junction entries created by this tool."""
managed_root = ESO_LOCAL_BASE / channel / "CachedData" / "AddOnsManaged"
map_data = _load_map(channel)
removed = 0
for name, a_uuid in list(map_data.get("managed", {}).items()):
uuid_dir = managed_root / a_uuid
info_file = uuid_dir / "content_installation_info.json"
try:
with open(info_file) as f:
meta = json.load(f)
if not meta.get(REROUTER_TAG):
continue
except Exception:
continue
print(f" [REMOVE] {name}")
if not dry_run:
_safe_remove_uuid_dir(uuid_dir, name, managed_root)
del map_data["managed"][name]
removed += 1
if not dry_run:
_save_map(channel, map_data)
print()
print(f"Removed {removed} rerouted addon(s).")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="ESO Game Pass Addons Rerouter",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
commands:
sync Link addons from source dir into the Game Pass addon system
status Show what the rerouter has set up
clean Remove all rerouter-created junctions
""",
)
p.add_argument(
"--channel",
default=DEFAULT_CHANNEL,
help=f"ESO channel folder name (default: {DEFAULT_CHANNEL})",
)
sub = p.add_subparsers(dest="command", metavar="command")
# sync
sp = sub.add_parser("sync", help="Sync addons source -> Game Pass managed system")
sp.add_argument(
"--source",
default=str(DEFAULT_SOURCE),
help=f"Addons directory to read from (default: {DEFAULT_SOURCE})",
)
sp.add_argument("--dry-run", action="store_true", help="Preview without making changes")
sp.add_argument("--remove-stale", action="store_true",
help="Remove junctions for addons no longer in source")
# status
sub.add_parser("status", help="Show rerouter status")
# clean
cp = sub.add_parser("clean", help="Remove all rerouter-created junctions")
cp.add_argument("--dry-run", action="store_true", help="Preview without making changes")
args = p.parse_args()
if args.command is None:
p.print_help()
return
channel = args.channel
if args.command == "sync":
_require_admin()
cmd_sync(
source=Path(args.source),
channel=channel,
dry_run=args.dry_run,
remove_stale=args.remove_stale,
)
elif args.command == "status":
cmd_status(channel)
elif args.command == "clean":
_require_admin()
cmd_clean(channel, dry_run=args.dry_run)
if __name__ == "__main__":
main()