mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Put a picked mod together beside the mods folder and move it in once it is complete.
Add a Mod copied straight into the mods folder, so a copy that stopped halfway left a half mod there, switched on at the game's next start. It now goes into a folder beside the mods folder and moves in with one rename; a failed or interrupted copy removes what it made. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
@@ -368,7 +368,9 @@ that folder is there already, and the archive stays; a mod folder whose files si
|
||||
that held the mod's own folder) gets them moved up; a `romfs`, `romfslite`, `exefs` or `cheats` folder put straight into
|
||||
the mods folder becomes a mod of its own, *Unnamed mod*. An archive that would write outside its folder is refused.
|
||||
Nothing is deleted. Add a Mod and Add a Mod Folder do the same for an archive or folder the player picks, which is
|
||||
unpacked or copied into the mods folder under a free name; Open Mods Folder opens it.
|
||||
unpacked or copied into the mods folder under a free name; Open Mods Folder opens it. The picked mod is put together
|
||||
in a folder beside the mods folder (`.adding-mod`) and moves in with one rename once it is complete, so a copy that
|
||||
stops halfway (a full disk, the launcher closed) leaves no half mod for the game to load (`mods.add`).
|
||||
|
||||
Each mod has an On/Off row; its description says what it changes (how many game files, code patches, cheats), which
|
||||
other mod changes the same files and which of the two wins, and why the game leaves it off when it does: a code module
|
||||
|
||||
@@ -21,12 +21,14 @@ Mods that change the game's code (code cheats, ExeFS patches) need the game buil
|
||||
code_waiting() names the enabled ones whose files the last build did not take in (build-cache/mod-code.json), and
|
||||
Play rebuilds the game first then. The build reads them from <game>/mods only.
|
||||
"""
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -475,9 +477,52 @@ def prepare(game_dir, log=None):
|
||||
return done
|
||||
|
||||
|
||||
STAGING = '.adding-mod' # beside the mods folder: where add() puts a mod together before it moves in
|
||||
STAGING_STALE = 24 * 3600 # seconds after which a staging folder is one a stopped launcher left
|
||||
|
||||
|
||||
def _staging(folder):
|
||||
"""A new, empty folder for add() to put a mod together in. It is beside the mods folder, not in it: the game
|
||||
and the launcher take every folder of the mods folder for a mod, and beside it is the same disk, so the
|
||||
finished mod moves in with one rename. The mods folder itself when nothing can be made beside it. What a
|
||||
launcher that was stopped in the middle left there a day ago or longer is removed."""
|
||||
try:
|
||||
for old in folder.parent.glob(STAGING + '*'):
|
||||
if old.is_dir() and time.time() - old.stat().st_mtime > STAGING_STALE:
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
stage = _unique(folder.parent, STAGING)
|
||||
stage.mkdir()
|
||||
except OSError:
|
||||
stage = _unique(folder, STAGING)
|
||||
stage.mkdir()
|
||||
return stage
|
||||
|
||||
|
||||
def _move_in(made, folder, name):
|
||||
"""Rename the finished mod folder made into the mods folder under name, or the first free name after it (looked
|
||||
for now, not before the copy: a folder of that name may have come meanwhile). Returns where it is."""
|
||||
for attempt in range(3):
|
||||
target = _unique(folder, name)
|
||||
try:
|
||||
os.rename(made, target)
|
||||
return target
|
||||
except OSError as e:
|
||||
if e.errno == errno.EXDEV: # the mods folder is on another disk than its parent (a mount, a junction)
|
||||
shutil.move(str(made), str(target))
|
||||
return target
|
||||
if attempt == 2:
|
||||
raise
|
||||
time.sleep(0.3) # a scanner still holds a file just written
|
||||
|
||||
|
||||
def add(game_dir, src, log=None):
|
||||
"""Add an archive or a folder the player picked as a mod: unpacked or copied into the mods folder under its name
|
||||
(a free one), in order for the game. Returns the new mod's folder."""
|
||||
(a free one), in order for the game. Returns the new mod's folder.
|
||||
|
||||
The mod is put together in a folder beside the mods folder (_staging) and moves in only when it is complete. A
|
||||
copy that stops halfway (a full disk, a file that cannot be read, the launcher closed) leaves nothing in the
|
||||
mods folder: copied straight into it, the half that had arrived was a mod of its own, switched on, at the
|
||||
game's next start."""
|
||||
src = Path(src)
|
||||
folder = ensure_mods_dir(game_dir)
|
||||
if src.is_dir():
|
||||
@@ -487,18 +532,24 @@ def add(game_dir, src, log=None):
|
||||
except ValueError:
|
||||
pass
|
||||
name = src.name if src.name.lower() not in MOD_TOP else UNNAMED
|
||||
target = _unique(folder, name)
|
||||
if src.name.lower() in MOD_TOP: # the player picked the mod's romfs (or atmosphere) folder itself
|
||||
target.mkdir()
|
||||
shutil.copytree(src, target / src.name.lower() if src.name.lower() in LOOSE_PARTS else target / src.name)
|
||||
else:
|
||||
shutil.copytree(src, target)
|
||||
elif src.suffix.lower() in ARCHIVES:
|
||||
target = _unique(folder, src.stem.strip().rstrip('.') or UNNAMED)
|
||||
unpack(src, target)
|
||||
name = src.stem.strip().rstrip('.') or UNNAMED
|
||||
else:
|
||||
raise ModError('it is no archive (.zip, .7z or .rar) and no folder')
|
||||
unwrap(target)
|
||||
stage = _staging(folder)
|
||||
try:
|
||||
made = stage / 'mod'
|
||||
if not src.is_dir():
|
||||
unpack(src, made)
|
||||
elif src.name.lower() in MOD_TOP: # the player picked the mod's romfs (or atmosphere) folder itself
|
||||
made.mkdir()
|
||||
shutil.copytree(src, made / src.name.lower() if src.name.lower() in LOOSE_PARTS else made / src.name)
|
||||
else:
|
||||
shutil.copytree(src, made)
|
||||
unwrap(made)
|
||||
target = _move_in(made, folder, name)
|
||||
finally: # also on Ctrl+C and when the window closes: nothing half made stays
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if log:
|
||||
log(f'mods: added {src} as {target.name}')
|
||||
return target
|
||||
|
||||
@@ -5117,6 +5117,71 @@ class ModsTest(Tmp):
|
||||
self.assertTrue((src / 'Picked.zip').is_file()) # the player's download stays where it was
|
||||
with self.assertRaises(self.mods.ModError):
|
||||
self.mods.add(self.game, src / 'Folder Mod' / 'Inner' / 'exefs' / 'E0F3CCFAF50065AE.ips')
|
||||
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), []) # nothing stays beside the mods folder
|
||||
|
||||
def test_a_mod_that_does_not_arrive_whole_leaves_nothing(self):
|
||||
"""A copy that stops halfway (a full disk, the launcher closed) or an archive that cannot be unpacked
|
||||
leaves no folder in the mods folder, which the game would load as a mod, and none beside it."""
|
||||
src = self.tmp / 'Downloads' / 'Big Mod'
|
||||
(src / 'romfs').mkdir(parents=True)
|
||||
for n in range(4):
|
||||
(src / 'romfs' / f'{n}.txt').write_text('x')
|
||||
self.file('Kept/romfs/k.txt')
|
||||
real_copytree = shutil.copytree
|
||||
for stop in (OSError(28, 'No space left on device'), KeyboardInterrupt()):
|
||||
copied = []
|
||||
|
||||
def copy(a, b, stop=stop, copied=copied):
|
||||
if len(copied) == 2:
|
||||
raise stop
|
||||
copied.append(b)
|
||||
return shutil.copy2(a, b)
|
||||
|
||||
def copytree(a, b, *args, copy=copy, **kw): # shutil.copytree with a copy that stops at the third file
|
||||
if args or kw: # shutil's own call for a folder inside, which hands the copy on
|
||||
return real_copytree(a, b, *args, **kw)
|
||||
return real_copytree(a, b, copy_function=copy)
|
||||
with mock.patch.object(shutil, 'copytree', copytree), self.assertRaises(type(stop)):
|
||||
self.mods.add(self.game, src)
|
||||
self.assertEqual(len(copied), 2) # two files had arrived
|
||||
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
|
||||
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [])
|
||||
(src.parent / 'Broken.zip').write_bytes(b'PK\x03\x04 not a zip')
|
||||
with self.assertRaises(self.mods.ModError):
|
||||
self.mods.add(self.game, src.parent / 'Broken.zip')
|
||||
self.assertEqual(self.tree(), ['Kept/romfs/k.txt'])
|
||||
self.assertEqual([m.name for m in self.mods.scan(self.game)], ['Kept'])
|
||||
self.assertEqual(self.mods.add(self.game, src).name, 'Big Mod') # and the same mod goes in whole after
|
||||
self.assertEqual(len(self.tree()), 5)
|
||||
|
||||
def test_a_mod_is_put_together_beside_the_mods_folder(self):
|
||||
"""While a mod is copied the mods folder does not hold it; it moves in under a name that is free then; and
|
||||
what a stopped launcher left beside the mods folder long ago is removed."""
|
||||
src = self.tmp / 'Downloads' / 'Late'
|
||||
(src / 'romfs').mkdir(parents=True)
|
||||
(src / 'romfs' / 'a.txt').write_text('a')
|
||||
left = self.game / self.mods.STAGING
|
||||
(left / 'mod' / 'romfs').mkdir(parents=True)
|
||||
old = time.time() - 2 * self.mods.STAGING_STALE
|
||||
os.utime(left, (old, old))
|
||||
fresh = self.game / (self.mods.STAGING + ' 2') # another add still at work
|
||||
fresh.mkdir()
|
||||
seen = []
|
||||
real_copytree = shutil.copytree
|
||||
|
||||
def copytree(a, b, *args, **kw):
|
||||
if not seen: # the first call; shutil calls it per folder
|
||||
seen.append((Path(b), self.tree()))
|
||||
(self.folder / 'Late' / 'romfs').mkdir(parents=True) # a folder of that name comes meanwhile
|
||||
return real_copytree(a, b, *args, **kw)
|
||||
with mock.patch.object(shutil, 'copytree', copytree):
|
||||
target = self.mods.add(self.game, src)
|
||||
self.assertEqual(seen[0][1], []) # nothing in the mods folder while copying
|
||||
self.assertEqual(seen[0][0].parent.parent, self.game) # it was put together beside it
|
||||
self.assertEqual(target.name, 'Late 2')
|
||||
self.assertEqual(self.tree(), ['Late 2/romfs/a.txt']) # the folder that came holds no file
|
||||
self.assertFalse(left.exists())
|
||||
self.assertEqual(list(self.game.glob(self.mods.STAGING + '*')), [fresh])
|
||||
|
||||
def test_code_waits_for_a_build_that_saw_it(self):
|
||||
import make_game
|
||||
|
||||
Reference in New Issue
Block a user