Files
NX2navi/launcher/feed.py
Claude c403547c8b Add the NX2navi launcher: a one-click setup that builds the game from the player's dump, starts it and keeps it up to date.
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>
2026-11-07 14:50:52 -05:00

145 lines
5.8 KiB
Python

"""Where updates come from.
Every release carries NX2navi-update.json (tools/make_launcher.py writes it):
{"format": 1, "version": "0.3.11", "build": "0.3.11+abc1234", "notes": "...",
"app": {"name": "NX2navi-app-0.3.11.zip", "size": n, "sha256": "..."},
"runtime": {"id": "py3.14.9-1", "name": "NX2navi-runtime-py3.14.9-1.zip", "size": n, "sha256": "..."},
"toolchain": "<lock digest>"}
The feed (app/<version>/feed.json, or the player's override in settings) is either
{"type": "github", "repo": "owner/name"} the newest GitHub release (or pre-release, on the preview channel)
that has the manifest; a token reads a private repository
{"type": "url", "url": "https://.../NX2navi-update.json"} one manifest; its files sit beside it
"""
import json
import urllib.parse
from . import net, versions
MANIFEST = 'NX2navi-update.json'
DEFAULT = {'type': 'github', 'repo': 'tangerineturtle1/NX2navi'}
GITHUB_API = 'https://api.github.com'
class FeedError(Exception):
pass
class Release:
def __init__(self, manifest, notes='', page='', assets=None, headers=None, prerelease=False):
self.manifest = manifest
self.version = manifest['version']
self.build = manifest.get('build', self.version)
self.notes = (manifest.get('notes') or notes or '').strip()
self.page = page
self.assets = assets or {} # name -> url
self.headers = headers or {} # extra request headers for the asset urls (a private repository's token)
self.prerelease = prerelease
def asset(self, key):
"""(url, headers, size, sha256) of the manifest's 'app' or 'runtime' file."""
entry = self.manifest.get(key)
if not entry:
raise FeedError(f'the {self.version} manifest has no {key} file')
url = self.assets.get(entry['name'])
if not url:
raise FeedError(f'the {self.version} release lacks {entry["name"]}')
return url, self.headers, entry.get('size'), entry.get('sha256')
def load(layout, settings):
feed = dict(DEFAULT)
try:
if layout.feed_file.is_file():
feed.update(json.loads(layout.feed_file.read_text(encoding='utf-8')))
except (OSError, ValueError):
pass
if settings.get('feed_repo'):
feed = {'type': 'github', 'repo': settings['feed_repo']}
return feed
def _check_manifest(m, where):
if not isinstance(m, dict) or m.get('format') != 1 or 'version' not in m or 'app' not in m:
raise FeedError(f'{where}: not an NX2navi update manifest')
versions.parse(m['version'])
return m
def _github(feed, channel, token, version):
api = feed.get('api', GITHUB_API).rstrip('/')
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
if token:
headers['Authorization'] = f'Bearer {token}'
try:
releases = net.get_json(f'{api}/repos/{feed["repo"]}/releases?per_page=20', headers, version=version)
except Exception as e:
code = getattr(e, 'code', None)
if code == 404:
raise FeedError(f'no releases found for {feed["repo"]} (a private repository needs a token)') from e
if code == 403:
raise FeedError('GitHub refused the request (rate limit); try again in an hour') from e
raise FeedError(f'GitHub could not be reached: {e}') from e
# The API lists the newest release first: the first one the channel takes that has a manifest is the answer.
for r in releases:
if r.get('draft') or (r.get('prerelease') and channel != 'preview'):
continue
assets = {a['name']: a for a in r.get('assets', [])}
if MANIFEST not in assets:
continue
if token: # a private repository's files come through the API with the token
urls = {n: a['url'] for n, a in assets.items()}
asset_headers = {'Accept': 'application/octet-stream', 'Authorization': f'Bearer {token}'}
else:
urls = {n: a['browser_download_url'] for n, a in assets.items()}
asset_headers = {}
try:
m = _check_manifest(json.loads(net.get_bytes(urls[MANIFEST], asset_headers, version=version)),
f'{r.get("tag_name")}/{MANIFEST}')
except (FeedError, ValueError, OSError) as e:
raise FeedError(f'{r.get("tag_name")}: {e}') from e
return Release(m, r.get('body') or '', r.get('html_url', ''), urls, asset_headers, bool(r.get('prerelease')))
return None
def _url(feed, version):
url = feed['url']
try:
m = _check_manifest(net.get_json(url, version=version), url)
except FeedError:
raise
except Exception as e:
raise FeedError(f'{url}: {e}') from e
base = url.rsplit('/', 1)[0] + '/'
assets = {e['name']: urllib.parse.urljoin(base, e['name']) for e in (m.get('app'), m.get('runtime')) if e}
return Release(m, assets=assets)
def latest(feed, channel='stable', token='', version='dev'):
"""The newest release the feed offers on the channel, or None when it has none."""
if feed.get('type') == 'url':
return _url(feed, version)
if feed.get('type') == 'github':
return _github(feed, channel, token, version)
raise FeedError(f'unknown feed type {feed.get("type")!r}')
def plain_notes(text, limit=12):
"""Release notes as short lines for the window: markdown bullets and headings without their marks."""
out = []
for line in (text or '').splitlines():
s = line.strip()
if not s or s.startswith(('<!--', '```', '---')):
continue
s = s.lstrip('#').strip()
for mark in ('- ', '* ', '+ '):
if s.startswith(mark):
s = s[2:]
s = s.replace('**', '').replace('`', '')
if s:
out.append(s)
if len(out) >= limit:
break
return out