mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
NX2navi-Setup.exe installs the launcher into %LOCALAPPDATA%\Programs\NX2navi without administrator rights, with shortcuts and an Apps entry. The launcher finds the player's .nup, downloads the build tools itself (the MSYS2 UCRT64 packages toolchain.lock.json pins, checked by sha256 and unpacked without pacman, and zbic from PyPI) and runs tools/make_game.py, whose steps it shows with their progress. Its window follows the game's options menu: colours sampled from it, the menu's filigree, the white glowing cursor, the Triforce in the title band and as the programs' icon, and all text in the game's Cetra Display, decoded from the player's own dump. Mouse, keyboard and an XInput pad drive it. Updates come from GitHub releases (NX2navi-update.json): the new version is downloaded, checked and staged beside the running one, the stub restarts into it and falls back to the previous version when it fails to start, and the game is rebuilt without the dump. make_game.py now keys its exefs and romfs steps on the game files alone, has --rebuild and --events, compiles only the files whose source, headers, flags or compiler changed, and replaces ocarina.exe only once the new one links. tools/make_launcher.py builds the release on Windows or Linux. Tested: tests/launcher_test.py (19 tests), the real toolchain download and install, and under Wine 11 the setup, the stub, the window on the bundled Windows Python, an update from a local feed by clicking Updates, the rollback of a version that fails to start, and the downloaded clang compiling and linking a program against the game's libraries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Merged onto the current port: make_game.py keeps the launcher's step keys, cached link and in-place swap, and its own runtime manifest (windres), AMD's FidelityFX library and the shader preparation, which becomes a step of its own ("shaders") that the launcher shows in its install, rebuild and update jobs. make_launcher.py ships the shader tools and, with --ffx, AMD's library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
6.1 KiB
Python
159 lines
6.1 KiB
Python
"""Updating the launcher's own release folder (launcher/paths.py, "app/").
|
|
|
|
An update is staged beside the running version and switched to with two small files, so a failed or interrupted
|
|
update never leaves a half-written release in use:
|
|
|
|
1. The app archive goes to data/downloads, is checked against the manifest's size and sha256 and unpacks into
|
|
app/<version>.part, which is renamed to app/<version> once it is complete and holds the expected VERSION.
|
|
2. A new Python (the manifest's runtime id differs from python/runtime-id.txt) unpacks into python.next; the stub
|
|
swaps it in at its next start, while no Python runs.
|
|
3. app/previous.txt gets the running version and app/current.txt the new one; the launcher starts the stub again,
|
|
which runs the new version. The stub goes back to previous.txt when the new version fails to start.
|
|
4. The new version rebuilds the game (launcher/jobs.py), then prune() removes every version but these two.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from . import net, versions
|
|
|
|
|
|
class UpdateError(Exception):
|
|
pass
|
|
|
|
|
|
def read_text(path, default=''):
|
|
try:
|
|
return Path(path).read_text(encoding='utf-8').strip()
|
|
except OSError:
|
|
return default
|
|
|
|
|
|
def write_text(path, text):
|
|
path = Path(path)
|
|
tmp = path.with_name(path.name + '.tmp')
|
|
tmp.write_text(text + '\n', encoding='utf-8')
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def current(layout):
|
|
return read_text(layout.apps / 'current.txt') or layout.version
|
|
|
|
|
|
def previous(layout):
|
|
return read_text(layout.apps / 'previous.txt')
|
|
|
|
|
|
def runtime_id(layout):
|
|
return read_text(layout.python / 'runtime-id.txt')
|
|
|
|
|
|
def safe_extract(zip_path, dest):
|
|
"""Unpack a zip into dest, refusing absolute names and names that climb out of it."""
|
|
dest = Path(dest)
|
|
root = dest.resolve()
|
|
with zipfile.ZipFile(zip_path) as z:
|
|
for info in z.infolist():
|
|
name = info.filename.replace('\\', '/')
|
|
if name.startswith('/') or ':' in name.split('/')[0] or '..' in name.split('/'):
|
|
raise UpdateError(f'{Path(zip_path).name}: unsafe path {info.filename}')
|
|
target = (dest / name).resolve()
|
|
if target != root and root not in target.parents:
|
|
raise UpdateError(f'{Path(zip_path).name}: unsafe path {info.filename}')
|
|
z.extractall(dest)
|
|
|
|
|
|
def _fetch(release, key, layout, progress, cancel, version):
|
|
url, headers, size, sha = release.asset(key)
|
|
entry = release.manifest[key]
|
|
dest = layout.downloads / entry['name']
|
|
net.download(url, dest, sha256=sha, size=size, headers=headers, progress=progress, cancel=cancel,
|
|
version=version)
|
|
return dest
|
|
|
|
|
|
def stage_app(layout, release, progress=None, cancel=None, version='dev'):
|
|
"""Download, check and unpack the release's app archive into app/<version>; returns that folder."""
|
|
zip_path = _fetch(release, 'app', layout, progress, cancel, version)
|
|
ver = versions.display(release.version)
|
|
final = layout.apps / ver
|
|
part = layout.apps / f'{ver}.part'
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
part.mkdir(parents=True)
|
|
try:
|
|
safe_extract(zip_path, part)
|
|
got = read_text(part / 'VERSION')
|
|
if versions.display(got) != ver or not (part / 'launcher' / 'start.pyw').is_file():
|
|
raise UpdateError(f'{zip_path.name} holds version {got or "?"} without a launcher, not {ver}')
|
|
if final.exists():
|
|
if final.resolve() == layout.app.resolve():
|
|
raise UpdateError(f'{ver} is the version that runs now')
|
|
shutil.rmtree(final)
|
|
os.replace(part, final)
|
|
except BaseException:
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
raise
|
|
zip_path.unlink(missing_ok=True)
|
|
return final
|
|
|
|
|
|
def runtime_needed(layout, release):
|
|
rt = release.manifest.get('runtime')
|
|
return bool(rt) and not layout.dev and rt.get('id') != runtime_id(layout)
|
|
|
|
|
|
def stage_runtime(layout, release, progress=None, cancel=None, version='dev'):
|
|
"""Unpack a new Python into python.next for the stub to swap in; False when the running one is current."""
|
|
if not runtime_needed(layout, release):
|
|
return False
|
|
zip_path = _fetch(release, 'runtime', layout, progress, cancel, version)
|
|
nxt = layout.root / 'python.next'
|
|
shutil.rmtree(nxt, ignore_errors=True)
|
|
part = layout.root / 'python.next.part'
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
part.mkdir()
|
|
safe_extract(zip_path, part)
|
|
if not (part / 'python.exe').is_file() or read_text(part / 'runtime-id.txt') != release.manifest['runtime']['id']:
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
raise UpdateError(f'{zip_path.name} is not the runtime {release.manifest["runtime"]["id"]}')
|
|
os.replace(part, nxt)
|
|
zip_path.unlink(missing_ok=True)
|
|
return True
|
|
|
|
|
|
def switch_to(layout, ver):
|
|
"""Make ver the version the stub starts, keeping the running one as previous."""
|
|
if not (layout.apps / ver / 'launcher' / 'start.pyw').is_file():
|
|
raise UpdateError(f'app/{ver} is not a complete release')
|
|
now = current(layout)
|
|
if now != ver:
|
|
write_text(layout.apps / 'previous.txt', now)
|
|
write_text(layout.apps / 'current.txt', ver)
|
|
|
|
|
|
def rollback(layout):
|
|
"""Back to previous.txt's version; False when there is none to go back to."""
|
|
prev = previous(layout)
|
|
if not prev or not (layout.apps / prev / 'launcher' / 'start.pyw').is_file():
|
|
return False
|
|
write_text(layout.apps / 'current.txt', prev)
|
|
(layout.apps / 'previous.txt').unlink(missing_ok=True)
|
|
return True
|
|
|
|
|
|
def prune(layout):
|
|
"""Remove every app version but the current and previous one, an old Python the stub set aside, and stale
|
|
downloads."""
|
|
keep = {current(layout), previous(layout)}
|
|
if layout.apps.is_dir():
|
|
for d in layout.apps.iterdir():
|
|
if d.is_dir() and d.name not in keep:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
for d in layout.root.glob('python.old*'):
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
if layout.downloads.is_dir():
|
|
for f in layout.downloads.iterdir():
|
|
if f.suffix in ('.part', '.zip'):
|
|
f.unlink(missing_ok=True)
|