Download and stage updates in the background, and install them only when the game is not running.

A check that finds a newer release (at start, then hourly while the launcher runs, also hidden during play) stages
it on a background-priority thread: the app into app/<version>, a new Python into app/<version>/python.staged rather
than python.next (which the stub would swap in under the running version), and staged.json written last to mark it
complete. Nothing is switched and nothing restarts. A failed staging is logged and tried again after the next check.

The staged release installs, through the existing switch, restart and ContinueUpdateJob path, when the launcher
starts and right after the game exits, never while a copy of the game runs. Install Updates Automatically (on by
default) can leave it to the Updates row instead. A version the stub rolled back from is not staged or installed by
itself again, prune keeps a staged release, and the Settings rows now fit the window with the new toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-09 10:22:37 -05:00
parent 0223d88520
commit cd20bdf09f
8 changed files with 698 additions and 37 deletions

View File

@@ -17,9 +17,11 @@ it. The code is `launcher/`, the release is made by `tools/make_launcher.py`, an
unpacks it, translates and compiles the game: 10 to 25 minutes. It keeps the PC awake meanwhile.
4. Selects **Play**. The launcher hides while the game runs and comes back when it exits.
From then on the launcher looks for a new version each time it opens (Settings can turn that off). When one is out,
the Updates row turns orange; selecting it downloads the new version, restarts the launcher in it and rebuilds the
game. Saves and settings are never touched.
From then on the launcher looks for a new version when it opens and every hour while it runs, also while it is
hidden during play (Settings can turn that off). A new version downloads in the background, the game playing on, and
installs when the game is not running: right after the game exits, or the next time the launcher opens. The launcher
then restarts in the new version and rebuilds the game. With Install Updates Automatically off (Settings) it waits
until the player selects Updates. Saves and settings are never touched.
## Look
@@ -39,8 +41,9 @@ identity (`NX2navi.Launcher`), so the taskbar groups and pins it under NX2navi r
Screens (`launcher/ui/app.py`): first run (dump, install folder, Desktop Shortcut, Check for Updates at Start,
Update Channel, Begin Installation); the steps of an install, update or rebuild with their progress, the build log's
last lines and Cancel, and after a failure Try Again and Copy Report; home (Play, Updates, Game Folder, Import Saves,
Rebuild the Game, Settings); settings (update check, Hide Launcher While Playing, Keep Build Cache, Update Channel, the folders,
Open Logs Folder, Uninstall, Back); uninstall (keep or remove the built game).
Rebuild the Game, Settings); settings (update check, Install Updates Automatically, Hide Launcher While Playing, Keep
Build Cache, Update Channel, the folders, Open Logs Folder, Uninstall, Back); uninstall (keep or remove the built game).
A screen with more rows than the window holds draws them closer together (`fit_rows`).
Controls: the mouse (hover moves the cursor, a click selects); the keyboard (arrows, Enter or Space, Esc, F5 checks
for updates); an XInput gamepad (d-pad or left stick, A, B, Y) while the window is in front.
@@ -56,7 +59,9 @@ python/ python.org's embeddable Python 3.14.9 with tkinter, num
app/current.txt the version the stub starts; app/previous.txt the one before it
app/<version>/ launcher/, runtime/, recomp/, tools/, bin/nx2navi_xlate.exe, VERSION,
toolchain.lock.json (the pinned build tools), feed.json (where updates come from), and in a
release made with --prompt-art, prompt-art/ (the button art the prompts are drawn from)
release made with --prompt-art, prompt-art/ (the button art the prompts are drawn from); a
release staged in the background also holds staged.json and the Python it brings,
python.staged/, until it installs
pydeps/ zbic, downloaded from PyPI on this PC
toolchain/msys64/ucrt64 MSYS2's clang and libraries, downloaded from the MSYS2 repository on this PC
toolchain/pkgs/ the downloaded package files
@@ -118,7 +123,46 @@ directly. Every address is https (plain http only to this PC), a redirect to htt
sent on when a redirect leaves the host it was meant for (`launcher/net.py`). A manifest that does not give a file's
size and sha256 is refused.
`jobs.UpdateDownloadJob`, before the restart (`launcher/updater.py`):
### In the background
The player never waits for a download and play is never interrupted. A check that finds a newer release (the check
at start, then one an hour after the last, also while the window is hidden during play: `jobs.RECHECK`) stages it
at once, without a screen (`jobs.StageJob`, `updater.stage`):
- The app archive is downloaded and checked as in step 1 below and unpacks into `app/<version>` as in step 2. A new
Python unpacks into `app/<version>/python.staged`, not `python.next`: the stub swaps `python.next` in at every
start, which would put the running version on the new Python. `app/<version>/staged.json`, written last, marks the
release complete (`updater.staged_info`); a folder without it, or one that lost its Python, is never installed and
is staged again. Nothing is switched and the launcher keeps running. The thread runs in Windows' background mode
(lower processor, disk and memory priority), so it shares only the network with the game.
- A staging that fails is logged in `launcher.log` and tried again after the next check; it never shows an error.
Selecting Updates then downloads the release with its steps on screen, as below.
- The Updates row reads "Downloading <version>", then "Ready · <version>". A staged release survives a restart of the
launcher, which finds it without a check (`updater.staged`). A pre-release counts only on the Preview channel; the
version the stub last rolled back from (`rolled_back` in `data/launcher.json`) is neither staged nor installed by
itself; a staged release newer than the feed's newest is passed over, as the release was withdrawn. Staging a
release removes the staged one it supersedes, and `updater.prune` keeps a staged release newer than the current
version.
`jobs.BackgroundUpdate` installs a staged release only while no copy of the game runs (`build.game_running`, and the
process the launcher started), no job holds the window and no staging is under way:
- by itself, with Install Updates Automatically on (Settings; the default): when the launcher starts, right after the
game exits (the launcher comes back from hiding), and, checked every minute, once a copy of the game started
elsewhere that held the install back has closed;
- when the player selects Updates, also with the setting off; selected during the download, it installs as soon as
the download is done.
Play always starts the game: a release staged meanwhile installs after the game exits. Installing
(`updater.install_staged`) moves `python.staged` to `python.next`, switches `app/current.txt` as in step 3, deletes
`staged.json` (so a version the stub rolls back from is not installed again) and restarts the launcher with
`--continue-update`; the new version finishes in `jobs.ContinueUpdateJob` (steps 4 to 9). A checkout runs from the
repository: it stages and installs nothing.
### Steps
Without a staged release (its staging failed, it is the version the stub rolled back from, or the launcher runs from a
checkout), selecting Updates runs `jobs.UpdateDownloadJob`, before the restart (`launcher/updater.py`):
1. **Download** `NX2navi-app-<version>.zip` (about 3 MB plus the shader translator) to `data/downloads`, resuming a cut
download, and check its size and sha256. When the manifest names a Python other than `python/runtime-id.txt`, also
@@ -148,7 +192,8 @@ The stub swaps `python.next` in while no Python runs, then starts the new versio
7. **Prepare the Shaders** again for the new build; shaders the translator already prepared come from the cache.
8. **Draw the Button Prompts**, with button art in the new version: only when the art, the prompt tools or the key
bindings changed, or a prompt folder is missing (a first version with art draws them all).
9. Every version but the current and the previous one is deleted.
9. Every version but the current and the previous one is deleted, except a staged release newer than the current
one.
When the new version fails to start (exit code 86 from `start.pyw`, or any error within six seconds), the stub sets
`current.txt` back to `previous.txt`'s version and starts it with `--rolled-back`. The game is never rebuilt while it
@@ -241,7 +286,10 @@ repository keeps old package files, so a release keeps building with the compile
`python tests/launcher_test.py` (Python 3.14): versions, settings, the toolchain resolver and installer against a
local fake repository, resumed and checked downloads, the certificate checks against a local HTTPS server (its
certificates made with the `openssl` command), the GitHub and URL feeds, staging, switching, rollback and
pruning, unsafe archives, the font decoding on a system font wrapped like the game's, `make_game.py` on a synthetic
pruning, unsafe archives, the background update (a release staged beside the running version without switching, the
`staged.json` marker, a staged release installed after a restart, no install while the game runs, the install once
it exited, nothing by itself with Install Updates Automatically off, a failed staging tried again after the next
check), the font decoding on a system font wrapped like the game's, `make_game.py` on a synthetic
`.nup` (events, skipped steps, `--rebuild` without the dump, step keys), the RomFS step beside the lift and compile
and its failure stopping the build, the prompts step with a stand-in prompt tool (when it runs again, and that it
makes no other step run), the button art a release copies and the packages and tools the prompts need in a release,

View File

@@ -1,12 +1,15 @@
"""The launcher's long tasks, run on a worker thread: install, update, rebuild and the update check. Each job is a
list of steps the window shows as rows; the window reads the job's state on a timer (rev changes on every change)
and never blocks on it. The same jobs run headless from launcher/cli.py."""
"""The launcher's long tasks, run on a worker thread: install, update, rebuild, the update check and the background
staging of an update. Each job is a list of steps the window shows as rows; the window reads the job's state on a
timer (rev changes on every change) and never blocks on it. The same jobs run headless from launcher/cli.py.
BackgroundUpdate decides when a staged update installs."""
import collections
import threading
import time
import traceback
from . import build, feed, gamefiles, net, toolchain, updater, versions
from . import build, feed, gamefiles, net, toolchain, updater, versions, winsys
RECHECK = 3600 # seconds between update checks while the launcher stays open, hidden during play included
PENDING, RUNNING, DONE, SKIPPED, FAILED = 'pending', 'running', 'done', 'skipped', 'failed'
@@ -407,3 +410,124 @@ class CheckJob(Job):
def update_available(ctx, release):
return bool(release) and versions.newer(release.version, ctx.layout.version)
class StageJob(Job):
"""A release downloaded, checked and unpacked beside the running version without switching to it
(updater.stage), while the launcher runs and the game too. Its thread runs at background priority, so the game
shares only the network with it. No screen shows it: the home screen's Updates row reads it."""
kind = 'stage'
def __init__(self, ctx, release):
ver = versions.display(release.version)
super().__init__(ctx, ['download'], ver)
self.release, self.ver = release, ver
def run(self):
winsys.background_thread()
self.begin('download')
prog = lambda d, t: self.progress('download', d / max(t, 1), f'{d / 1e6:.1f} of {t / 1e6:.1f} MB') # noqa: E731
updater.stage(self.ctx.layout, self.release, progress=prog, cancel=self.cancelled, version=self.ctx.version)
self.finish('download')
class BackgroundUpdate:
"""The update the launcher fetches by itself, and when it goes in (docs/launcher.md, "Updating"). The window
calls poll() on its timer, checked() after each update check, and install() when the launcher starts, when the
game exits, when a game that held an install up has closed, and when the player selects Updates.
A newer release is staged once per update check (StageJob); a staging that fails is logged and tried again after
the next check. A staged release installs only while no copy of the game runs, no job holds the window and no
staging is under way, and by itself only with Install Updates Automatically on. Installing switches
app/current.txt to it and restarts the launcher (restart(args), the stub), whose new version finishes in
ContinueUpdateJob. A checkout has nothing to switch to: there (enabled False) nothing is staged or installed."""
def __init__(self, ctx, restart, enabled=True):
self.ctx, self.restart, self.enabled = ctx, restart, enabled
self.job = None # the StageJob running, or the last one
self.tried = '' # the release staged, or found staged, since the last update check
self.asked = False # the player selected Updates during the download: it installs once staged
self.waiting = False # an install had to wait (the game ran, a download ran): the window tries again
self.restarting = False # the last install restarted the launcher
self.error = '' # why the last install failed
def staging(self):
return self.job is not None and self.job.result is None
def ready(self, release=None):
"""The staged release an install would take now (updater.staged_info's dict), or None. With release (the
last check's answer), a staged release newer than it is passed over: the feed no longer offers it."""
if not self.enabled:
return None
s = self.ctx.settings
m = updater.staged(self.ctx.layout, s.get('channel', 'stable'), s.get('rolled_back', ''))
if m and release and versions.newer(m['version'], release.version):
return None
return m
def checked(self):
"""An update check ended: the release it found is staged at the next poll, also one that failed before."""
self.tried = ''
def poll(self, release, busy=False):
"""Report a staging that ended ('staged' or 'failed'), or start staging release when it is newer than the
running version and not staged yet ('started'); None when nothing changed. busy: a job holds the window
(an install, update or rebuild); staging waits for it, as an update's prune() clears the same folders."""
j = self.job
if j and j.result is not None and not getattr(j, 'seen', False):
j.seen = True
if j.result == 'ok':
self.ctx.log(f'background update: {j.ver} is staged')
return 'staged'
self.ctx.log(f'background update: {j.ver} was not staged ({j.result}: {j.error}); it is tried again '
'after the next update check')
return 'failed'
if not self.enabled or busy or self.staging() or not release:
return None
ver = versions.display(release.version)
if ver == self.tried:
return None
self.tried = ver
if (not update_available(self.ctx, release) or ver == self.ctx.settings.get('rolled_back')
or updater.staged_info(self.ctx.layout, ver)):
return None
self.ctx.log(f'background update: staging {ver}')
self.job = StageJob(self.ctx, release).start()
return 'started'
def install(self, game_dir, release=None, why='', asked=False, running=False):
"""Install the staged release now unless something stops it, and restart into it. asked: the player
selected Updates (now or during the download), which Install Updates Automatically off does not stop;
running: the window knows the game runs (the process it started); game_dir: a copy started elsewhere counts
too (build.game_running). Returns the version installed, or '' (then waiting says whether it is worth trying
again)."""
if not self.enabled or not (asked or self.asked or self.ctx.settings.get('auto_install')):
return ''
m = self.ready(release)
if not m:
return ''
ver = m['version']
why_not = ('the game is running' if running or (game_dir and build.game_running(game_dir)) else
f'{self.job.ver} is downloading' if self.staging() else '')
if why_not:
if not self.waiting:
self.ctx.log(f'background update: {ver} waits: {why_not}')
self.waiting = True
return ''
old = versions.display(self.ctx.layout.version)
try:
updater.install_staged(self.ctx.layout, ver)
except (OSError, updater.UpdateError) as e:
self.error = str(e)
self.waiting = False
self.ctx.log(f'background update: {ver} could not be installed: {e}')
return ''
self.waiting = self.asked = False
self.error = ''
self.ctx.log(f'background update: installing {ver}' + (f' ({why})' if why else ''))
try:
self.restarting = bool(self.restart(['--continue-update', old]))
except OSError as e: # the stub did not start: the new version runs at the launcher's next start
self.ctx.log(f'background update: the launcher could not restart: {e}')
self.restarting = False
return ver

View File

@@ -7,7 +7,8 @@ from pathlib import Path
DEFAULTS = {
'game_dir': '', # the built game; empty until the first install picks one
'dump': '', # the .nup (or the folder with exefs/ and romfs/) the game was built from
'auto_check': True, # look for a new version when the launcher opens
'auto_check': True, # look for a new version when the launcher opens, and every hour while it runs
'auto_install': True, # install a downloaded update by itself whenever the game is not running
'channel': 'stable', # 'stable' or 'preview' (GitHub pre-releases)
'hide_while_playing': True, # hide the window while the game runs, show it again when it exits
'keep_cache': True, # keep build-cache/ (the lift and objects) so an update recompiles only changes
@@ -16,6 +17,7 @@ DEFAULTS = {
'github_token': '', # only for a private release repository; stored as plain text
'last_check': 0.0,
'last_seen': '', # the newest version the player was told about
'rolled_back': '', # the last version that failed to start; it is never downloaded or installed by itself
}
@@ -47,7 +49,8 @@ class Settings(dict):
def reset(self):
"""Default Settings: the choices go back to their defaults; the game folder, dump and history stay."""
keep = {k: self[k] for k in ('game_dir', 'dump', 'last_check', 'last_seen', 'feed_repo', 'github_token')}
keep = {k: self[k] for k in ('game_dir', 'dump', 'last_check', 'last_seen', 'feed_repo', 'github_token',
'rolled_back')}
self.clear()
self.update(DEFAULTS)
self.update(keep)

View File

@@ -106,6 +106,12 @@ class App:
self.release = None
self.check_error = ''
self.checked_at = 0.0
self.check_started = 0.0
# the update fetched in the background and installed when the game is not running; a checkout and the
# selftest have nothing to switch to
self.bg = jobs.BackgroundUpdate(ctx, lambda a: winsys.restart(self.layout, a),
enabled=not self.layout.dev and not args.selftest)
self.row_gap = GAP
self.message = ''
self.save_message = '' # the last Import Saves result, shown in its description
self.dumps = None
@@ -133,6 +139,7 @@ class App:
self.root.after(80, self.animate)
self.root.after(150, self.tick)
self.root.after(50, self.poll_gamepad)
self.root.after(60000, self.minute)
self.center()
self.framed = not winsys.frameless(root) # Windows: the band is the title bar
self.root.after(60, self.native_window)
@@ -386,7 +393,7 @@ class App:
for i, r in enumerate(self.rows):
if r.rect and r.focusable:
x0, y0, x1, y1 = r.rect
if x0 <= x <= x1 and y0 - self.X(GAP / 2) <= y <= y1 + self.X(GAP / 2):
if x0 <= x <= x1 and y0 - self.X(self.row_gap / 2) <= y <= y1 + self.X(self.row_gap / 2):
return i
return None
@@ -458,6 +465,8 @@ class App:
def on_close(self):
if self.job and self.job.result is None:
self.job.cancel()
if self.bg.staging(): # what it downloaded is resumed, and its unfinished folder replaced, next time
self.bg.job.cancel()
self.root.destroy()
# ---------------------------------------------------------------- screens
@@ -467,6 +476,8 @@ class App:
gd = self.settings.get('game_dir')
if a.rolled_back:
self.ctx.log(f'the stub went back from {a.rolled_back} to {self.layout.version}')
self.settings['rolled_back'] = versions.display(a.rolled_back) # never installed again by itself
self.settings.save()
for old in self.layout.root.glob('python.old*'): # the Python an update replaced (launcher/native/nx2navi.c)
shutil.rmtree(old, ignore_errors=True)
if gd or self.settings.get('dump'):
@@ -481,6 +492,8 @@ class App:
elif gd and build.built_version(gd):
self.show('home')
self.after_update_notice()
if self.install_staged('a staged update waited for the launcher to start'):
return
if self.settings.get('auto_check'):
self.check_now()
else:
@@ -562,8 +575,9 @@ class App:
self.toggle('Desktop Shortcut', 'desktop_shortcut',
'Put an NX2navi shortcut on the desktop. The Start menu always has one.'),
self.toggle('Check for Updates at Start', 'auto_check',
'Look for a new version of NX2navi each time the launcher opens. Nothing is installed '
'until you choose Update.'),
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
'open. A new version downloads in the background and installs when you are not playing; '
'Settings can make it wait until you choose Updates.'),
self.channel_row(),
Row('divider'),
Row('action', 'Begin Installation', on=self.begin_install, desc=begin_desc, enabled=True),
@@ -659,7 +673,14 @@ class App:
built = build.built_version(gd)
mine = versions.display(self.layout.version)
play_value = 'Ocarina of Time'
if self.release and jobs.update_available(self.ctx, self.release):
staged = self.bg.ready(self.release)
if staged:
upd = Row('value', 'Updates', f'Ready · {staged["version"]}', 'orange_arrow', big=True, icon='refresh',
badge=True, on=self.start_update, focus_value=True, desc=lambda: self.staged_desc(staged))
elif self.bg.staging():
upd = Row('value', 'Updates', f'Downloading {self.bg.job.ver}', 'orange_arrow', big=True,
icon='refresh', on=self.start_update, focus_value=True, desc=self.staging_desc)
elif self.release and jobs.update_available(self.ctx, self.release):
nv = versions.display(self.release.version)
upd = Row('value', 'Updates', f'Version {nv}', 'orange_arrow', big=True, icon='refresh', badge=True,
on=self.start_update, focus_value=True, desc=self.update_desc)
@@ -684,6 +705,8 @@ class App:
play_desc = ('Start Ocarina of Time. The launcher steps aside while you play and comes back when you quit.',)
if built and versions.display(built) != mine:
play_desc += (f'The game was built with {versions.display(built)}. Rebuild the Game brings it to {mine}.',)
if staged and self.settings.get('auto_install'):
play_desc += (f'NX2navi {staged["version"]} is downloaded and installs after you quit the game.',)
if self.message:
play_desc = (self.message,) + play_desc
return [
@@ -750,6 +773,32 @@ class App:
f'minutes. Your saves and settings stay as they are.'],
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes}
def staged_desc(self, m):
"""The Updates row of a release downloaded in the background (BackgroundUpdate.ready)."""
nv, mine = m['version'], versions.display(self.layout.version)
notes = feed.plain_notes(m.get('notes', ''), 4)
if self.settings.get('auto_install'):
text = (f'You have {mine}. Version {nv} is downloaded and installs when you are not playing: after you '
'quit the game, or when NX2navi next opens. Select this row to install it now.')
else:
text = f'You have {mine}. Version {nv} is downloaded. Select this row to install it.'
return {'title': f'Version {nv} Is Ready', 'text': [text],
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes,
'note': 'Installing restarts the launcher and rebuilds the game in a few minutes. Your saves and '
'settings stay as they are.'}
def staging_desc(self):
nv, mine = self.bg.job.ver, versions.display(self.layout.version)
if self.bg.asked:
then = 'It installs as soon as the download is done.'
elif self.settings.get('auto_install'):
then = 'It installs when you are not playing. Select this row to install it as soon as it is downloaded.'
else:
then = 'Select this row to install it as soon as it is downloaded.'
return {'title': f'Downloading Version {nv}',
'text': [f'You have {mine}. Version {nv} is downloading in the background; you can play meanwhile. '
f'{then}']}
def ago(self, t):
d = time.time() - t
if d < 90:
@@ -764,12 +813,14 @@ class App:
if not force and time.time() - self.settings.get('last_check', 0) < 600 and self.release:
return
self.check_error = ''
self.check_started = time.time()
self.check = jobs.CheckJob(self.ctx).start()
if self.screen == 'home':
self.show('home', self.focus)
def check_done(self):
c = self.check
self.bg.checked() # the release found is staged at the next tick, also one whose staging failed before
if c.result == 'ok':
self.release = c.release
self.checked_at = time.time()
@@ -819,7 +870,10 @@ class App:
self.root.deiconify()
self.root.lift()
if code not in (0, None):
self.ctx.log(f'the game ended with code {code}')
self.message = f'The game ended with code {code}. Its log is logs\\nx2navi.log in the game folder.'
if self.install_staged('the game exited'):
return
self.show('home', self.focus)
def open(self, path):
@@ -830,15 +884,62 @@ class App:
self.render()
def start_update(self):
if not self.release:
return
"""The Updates row: install the release staged in the background, have the one downloading install once it
is staged, or else download it here with its steps on screen (as when the background download failed)."""
gd = self.settings.get('game_dir')
if gd and build.game_running(gd):
self.message = 'Close the game first: the update rebuilds it.'
self.show('home', self.focus)
return
if self.bg.staging():
self.bg.asked = True
self.message = f'Version {self.bg.job.ver} installs as soon as it is downloaded.'
self.show('home', self.focus)
return
if self.bg.ready(self.release):
if not self.install_staged('selected', asked=True):
self.message = f'The update could not be installed: {self.bg.error or "see the launcher log"}.'
self.show('home', self.focus)
return
if not self.release:
return
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
def install_staged(self, why, asked=False):
"""Install the release staged in the background when nothing stops it (BackgroundUpdate.install) and close
for the restart into it; True when it was installed."""
if self.job and self.job.result is None:
return False
running = bool(self.game_proc and self.game_proc.poll() is None)
ver = self.bg.install(self.settings.get('game_dir'), self.release, why, asked=asked, running=running)
if not ver:
return False
if self.bg.restarting:
self.message = f'Installing NX2navi {ver}…'
self.root.after(200, self.root.destroy)
else:
self.message = f'NX2navi {ver} is installed: start NX2navi again to finish it.'
if self.screen == 'home':
self.show('home', self.focus)
return True
def background_event(self, ev):
"""A background staging started or ended (BackgroundUpdate.poll)."""
if ev in ('staged', 'failed') and (self.bg.asked or self.bg.waiting):
if self.install_staged('the download finished'):
return
if ev == 'failed' and self.bg.asked:
# the player chose to update: download it here instead, where its error and Try Again show
self.bg.asked = False
gd = self.settings.get('game_dir')
if (self.screen == 'home' and self.release and jobs.update_available(self.ctx, self.release)
and not (self.game_proc and self.game_proc.poll() is None)
and not (gd and build.game_running(gd))):
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
return
if self.screen == 'home':
self.show('home', self.focus)
def start_rebuild(self):
gd = self.settings.get('game_dir')
self.run_job(jobs.RebuildJob(self.ctx, gd, self.settings.get('dump')), 'home')
@@ -850,8 +951,12 @@ class App:
dump = self.settings.get('dump')
return [
self.toggle('Check for Updates at Start', 'auto_check',
'Look for a new version of NX2navi each time the launcher opens. Nothing is installed '
'until you choose Update.'),
'Look for a new version of NX2navi when the launcher opens, and every hour while it stays '
'open (also while you play). A new version downloads in the background.'),
self.toggle('Install Updates Automatically', 'auto_install',
'Install a downloaded update by itself when you are not playing: after you quit the game, '
'or when NX2navi opens. Off: it waits until you select Updates. Updates download in the '
'background either way.'),
self.toggle('Hide Launcher While Playing', 'hide_while_playing',
'Hide this window while the game runs; it comes back when you quit the game.'),
self.toggle('Keep Build Cache', 'keep_cache',
@@ -1072,6 +1177,9 @@ class App:
if self.check and self.check.result is not None and not getattr(self.check, 'seen', False):
self.check.seen = True
self.check_done()
ev = self.bg.poll(self.release, busy=bool(self.job and self.job.result is None))
if ev:
self.background_event(ev)
if self.screen == 'job' and self.job:
if self.job.rev != self.job_rev:
self.job_rev = self.job.rev
@@ -1088,6 +1196,21 @@ class App:
self.ctx.log(f'tick: {e!r}')
self.root.after(150, self.tick)
def minute(self):
"""Once a minute, also while the window is hidden during play: look for a new version an hour after the last
look (a new one then downloads in the background), and install a staged release that waited for a copy of
the game the launcher did not start to close."""
try:
if not self.args.selftest and self.screen in ('home', 'settings'):
last = max(self.settings.get('last_check', 0.0), self.check_started)
if self.settings.get('auto_check') and time.time() - last >= jobs.RECHECK:
self.check_now()
if self.bg.waiting and not (self.game_proc and self.game_proc.poll() is None):
self.install_staged('the game closed')
except Exception as e:
self.ctx.log(f'minute: {e!r}')
self.root.after(60000, self.minute)
def animate(self):
if self.cursor_frames:
self.cursor_k = (self.cursor_k + 1) % len(self.cursor_frames)
@@ -1105,13 +1228,34 @@ class App:
if not (0 <= self.focus < len(self.rows)) or not self.rows[self.focus].focusable:
self.focus = self.job_focus()
x0, x1, y = ROWS
for i, r in enumerate(self.rows):
self.draw_row(r, self.X(x0), self.X(y), self.X(x1), self.X(y + r.h))
y += r.h + GAP
heights, self.row_gap = self.fit_rows(y, H - 46 if self.screen == 'job' else H - 12)
for r, h in zip(self.rows, heights):
self.draw_row(r, self.X(x0), self.X(y), self.X(x1), self.X(y + h))
y += h + self.row_gap
self.draw_status()
self.draw_cursor()
self.draw_desc()
def fit_rows(self, top, bottom):
"""The height of each row and the gap between rows (logical pixels) that end the rows above bottom. A screen
with more rows than the window holds (Settings with every row, a job with every step) narrows its gaps
first, then its dividers, and last makes its rows a little lower."""
rows = self.rows
hs = [r.h for r in rows]
gaps = max(1, len(rows) - 1)
room = bottom - top
for stage in ('gaps', 'dividers', 'rows'):
if stage == 'dividers':
hs = [min(h, 14) if r.kind == 'divider' else h for r, h in zip(rows, hs)]
elif stage == 'rows':
over = sum(hs) + 6 * gaps - room
cut = -(-over // max(1, sum(1 for r in rows if r.kind != 'divider')))
hs = [h if r.kind == 'divider' else max(30, h - cut) for r, h in zip(rows, hs)]
gap = min(GAP, (room - sum(hs)) // gaps)
if gap >= 6:
return hs, gap
return hs, 6
def draw_status(self):
if self.screen != 'job' or not self.job:
return
@@ -1215,6 +1359,7 @@ class App:
elif r.style == 'orange_arrow':
self.orange(ix0, iy0, ix1, iy1)
cw, ch = X(9), X(12)
vfont = self.fit_font(vfont, r.value, (ix1 - ix0) - X(30) - cw, 0.8)
label_w = vfont.measure(r.value)
total = label_w + X(10) + cw
sx = cx - total // 2

View File

@@ -168,10 +168,17 @@ def states(app):
'saves\\backup 2026-11-08 17.30.12.')
app.show('home', next(i for i, r in enumerate(app.rows_home()) if r.label == 'Import Saves'))
def update_staged(): # a release downloaded in the background, waiting for the game to be closed
rel = fake_release(ahead_of(ctx.version))
staged = {'version': rel.version, 'runtime': '', 'prerelease': False, 'notes': rel.notes}
app.bg.ready = lambda release=None: staged
app.release, app.message = rel, ''
app.show('home', 1)
return [('1-first-run', first_run), ('2-installing', installing), ('3-home', home),
('4-update-ready', update_ready), ('5-updating', updating), ('6-settings', settings),
('7-build-failed', failed), ('8-uninstall', uninstall), ('9-import-saves', import_saves),
('10-shaders', shaders)]
('10-shaders', shaders), ('11-update-staged', update_staged)]
def run(app, shots):

View File

@@ -10,7 +10,14 @@ update never leaves a half-written release in use:
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.
The launcher also stages a release in the background while it runs, the game included (stage()), and installs it
later, when the game is not running (install_staged()). Such a release waits in app/<version> with its Python, when it
brings one, in app/<version>/python.staged rather than python.next: the stub swaps python.next in at every start, and
the running version must not start on another Python before current.txt names the new one. app/<version>/staged.json,
written last, says the release is complete; a folder without it is never installed and is staged again.
"""
import json
import os
import shutil
import zipfile
@@ -18,6 +25,9 @@ from pathlib import Path
from . import net, versions
STAGED = 'staged.json' # in app/<version>: the background staging of that release finished
STAGED_PYTHON = 'python.staged' # in app/<version>: the Python the release brings, until it installs
class UpdateError(Exception):
pass
@@ -103,14 +113,15 @@ def runtime_needed(layout, release):
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."""
def stage_runtime(layout, release, progress=None, cancel=None, version='dev', dest=None):
"""Unpack a new Python into dest (python.next, for the stub to swap in at the next start); 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'
nxt = Path(dest) if dest else layout.root / 'python.next'
shutil.rmtree(nxt, ignore_errors=True)
part = layout.root / 'python.next.part'
part = nxt.with_name(nxt.name + '.part')
shutil.rmtree(part, ignore_errors=True)
part.mkdir()
safe_extract(zip_path, part)
@@ -122,6 +133,97 @@ def stage_runtime(layout, release, progress=None, cancel=None, version='dev'):
return True
def prerelease(release):
"""True for a GitHub pre-release or a version with a pre-release tag (0.5.0-rc1): only the Preview channel
installs it."""
try:
return bool(release.prerelease) or versions.parse(release.version)[1] != (1,)
except ValueError:
return True
def stage(layout, release, progress=None, cancel=None, version='dev'):
"""Download, check and unpack a release into app/<version>, with the Python it brings in python.staged there,
without switching to it; staged.json, written last, marks it complete. Earlier staged releases it supersedes are
removed. Returns the folder."""
ver = versions.display(release.version)
folder = stage_app(layout, release, progress=progress, cancel=cancel, version=version)
runtime = ''
if runtime_needed(layout, release):
stage_runtime(layout, release, progress=progress, cancel=cancel, version=version,
dest=folder / STAGED_PYTHON)
runtime = release.manifest['runtime']['id']
write_text(folder / STAGED, json.dumps({'version': ver, 'runtime': runtime, 'prerelease': prerelease(release),
'notes': release.notes}))
keep = {ver, current(layout), previous(layout)}
for d in layout.apps.iterdir():
if (d.is_dir() and d.name not in keep and (d / STAGED).is_file()
and d.resolve() != Path(layout.app).resolve()):
shutil.rmtree(d, ignore_errors=True)
return folder
def staged_info(layout, ver):
"""What staged.json says about app/<ver> ({'version', 'runtime', 'prerelease', 'notes'}) when stage() finished
it and it is still whole (its launcher, its VERSION, the Python it needs), else None."""
folder = layout.apps / ver
try:
m = json.loads((folder / STAGED).read_text(encoding='utf-8'))
except (OSError, ValueError):
return None
if not isinstance(m, dict) or m.get('version') != ver:
return None
if versions.display(read_text(folder / 'VERSION')) != ver or not (folder / 'launcher' / 'start.pyw').is_file():
return None
rt = m.get('runtime') or ''
if rt and rt != runtime_id(layout) and read_text(folder / STAGED_PYTHON / 'runtime-id.txt') != rt:
return None
return m
def staged(layout, channel='stable', skip=''):
"""The newest release stage() left complete that is newer than the running version, as staged_info() gives it,
or None. A pre-release counts only on the Preview channel; skip is a version that failed to start."""
best = None
if not layout.apps.is_dir():
return None
for d in layout.apps.iterdir():
if not d.is_dir() or d.name == skip or not (d / STAGED).is_file():
continue
try:
if not versions.newer(d.name, layout.version) or (best and not versions.newer(d.name, best['version'])):
continue
except ValueError:
continue
m = staged_info(layout, d.name)
if m and (channel == 'preview' or not m.get('prerelease')):
best = m
return best
def install_staged(layout, ver):
"""Make a release stage() finished the one the stub starts at its next start: its Python goes to python.next for
the stub to swap in, current.txt names it (previous.txt the running version), and staged.json goes, so a version
the stub rolls back from is not installed again by itself."""
m = staged_info(layout, ver)
if not m:
raise UpdateError(f'app/{ver} is not a complete staged release')
py = layout.apps / ver / STAGED_PYTHON
nxt = layout.root / 'python.next'
moved = bool(m.get('runtime')) and m['runtime'] != runtime_id(layout)
if moved:
shutil.rmtree(nxt, ignore_errors=True)
os.replace(py, nxt)
try:
switch_to(layout, ver)
except BaseException:
if moved: # the running version stays: it must not start on the new Python
os.replace(nxt, py)
raise
shutil.rmtree(py, ignore_errors=True)
(layout.apps / ver / STAGED).unlink(missing_ok=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():
@@ -143,12 +245,13 @@ def rollback(layout):
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)}
"""Remove every app version but the current and previous one and a staged release newer than the current one,
an old Python the stub set aside, and stale downloads."""
now = current(layout)
keep = {now, previous(layout)}
if layout.apps.is_dir():
for d in layout.apps.iterdir():
if d.is_dir() and d.name not in keep:
if d.is_dir() and d.name not in keep and not _waiting(layout, d.name, now):
shutil.rmtree(d, ignore_errors=True)
for d in layout.root.glob('python.old*'):
shutil.rmtree(d, ignore_errors=True)
@@ -156,3 +259,11 @@ def prune(layout):
for f in layout.downloads.iterdir():
if f.suffix in ('.part', '.zip'):
f.unlink(missing_ok=True)
def _waiting(layout, name, now):
"""True for a staged release newer than version now: it waits to be installed."""
try:
return versions.newer(name, now) and staged_info(layout, name) is not None
except ValueError:
return False

View File

@@ -201,6 +201,22 @@ def keep_awake(on):
pass
def background_thread():
"""Windows: put the calling thread in background mode (SetThreadPriority THREAD_MODE_BACKGROUND_BEGIN), which
lowers its processor, disk and memory priority, so work done beside the game takes only what the game leaves.
False where that is not possible."""
if os.name != 'nt':
return False
try:
from ctypes import wintypes
k = ctypes.WinDLL('kernel32') # a private handle: the argument types set here touch no other caller
k.GetCurrentThread.restype = wintypes.HANDLE
k.SetThreadPriority.argtypes = (wintypes.HANDLE, ctypes.c_int)
return bool(k.SetThreadPriority(k.GetCurrentThread(), 0x00010000))
except (AttributeError, OSError):
return False
# What the launcher puts in its folder (launcher/paths.py, the stub's python.next and python.old-<n>). Uninstall deletes
# only these: the default game folder is <root>\game, and a folder given to the setup with --dest may hold other things.
OWNED = ('NX2navi.exe', 'app', 'python', 'python.next', 'pydeps', 'data', 'toolchain')
@@ -226,7 +242,7 @@ def uninstall_plan(layout, remove_game=''):
def uninstall_script(gone, empty):
"""The cmd.exe line that deletes them once the launcher has exited: a file with del, a folder with rmdir /s, the
folders that must be empty with a plain rmdir (which leaves a folder that still holds something). A path holding
% is left out: cmd.exe expands %NAME% even inside quotes, so a folder named C:\%HOMEPATH% would delete another."""
% is left out: cmd.exe expands %NAME% even inside quotes, so a folder named C:\\%HOMEPATH% would delete another."""
steps = ['ping -n 3 127.0.0.1 >nul']
for p in gone:
if '%' not in str(p):

View File

@@ -8,6 +8,9 @@ Run: python tests/launcher_test.py (Python 3.14 for the zstd tests; the window
server with certificates the openssl command makes (an expired one, an unknown one), the clock comparison
- feed: GitHub releases (stable, preview, private with a token) and a plain manifest URL, served locally
- updater: staging a release, switching, rollback, pruning, refusing unsafe archives
- background updates: a release staged beside the running version without switching to it, the ready marker, a
staged release found and installed after a restart, no install while the game runs, the install once it exited,
nothing by itself with Install Updates Automatically off, a failed staging tried again after the next check
- gamefiles: the .bfotf font wrapping and zstd dictionaries, on a font of this system made into a fake RomFS
- the button art of a release: the files tools/prompt_art.py copies, and the packages and tools the prompts need
- make_game.py: a synthetic .nup through the check, exefs and romfs steps with --events, skipped steps on a second
@@ -689,6 +692,210 @@ class FeedTest(Tmp):
self.assertFalse((self.layout.apps / '1.0.2').exists())
class BackgroundUpdateTest(Tmp):
"""An update fetched while the launcher runs (updater.stage, jobs.StageJob, jobs.BackgroundUpdate): staged beside
the running version without switching to it, and installed only when the game is not running, by itself only
with Install Updates Automatically on. The releases come from the local server through a URL feed."""
def setUp(self):
super().setUp()
self.srv = Server(self.tmp)
self.addCleanup(self.srv.close)
self.layout = self.installed('0.4.0')
self.game = str(self.tmp / 'game')
self.running = False # what build.game_running answers: a copy of the game the launcher did not start
saved = build.game_running
build.game_running = lambda game_dir, exe='ocarina.exe': self.running
self.addCleanup(setattr, build, 'game_running', saved)
self.restarts = []
def installed(self, ver):
"""An installed launcher's folder (not a checkout) running version ver on the Python py-1."""
root = self.tmp / 'nx'
app = root / 'app' / ver
(app / 'launcher').mkdir(parents=True)
(app / 'launcher' / 'start.pyw').write_text('')
(app / 'VERSION').write_text(ver + '\n')
(root / 'app' / 'current.txt').write_text(ver + '\n')
(root / 'python').mkdir()
(root / 'python' / 'runtime-id.txt').write_text('py-1\n')
layout = Layout(root=root, app=app).ensure()
self.assertFalse(layout.dev)
return layout
def publish(self, ver, runtime='', pre=False, damaged=False):
"""A release of ver on the local server, read through a URL feed; runtime: the id of the Python it brings;
damaged: its manifest gives the wrong sha256 for the app archive."""
z = make_app_zip(self.tmp, ver)
m = manifest_for(z, ver)
if runtime:
r = self.tmp / f'NX2navi-runtime-{runtime}.zip'
with zipfile.ZipFile(r, 'w') as zf:
zf.writestr('python.exe', 'MZ')
zf.writestr('runtime-id.txt', runtime + '\n')
m['runtime'] = {'id': runtime, 'name': r.name, 'size': r.stat().st_size, 'sha256': sha(r.read_bytes())}
if damaged:
m['app']['sha256'] = '0' * 64
(self.tmp / f'm-{ver}.json').write_text(json.dumps(m))
rel = feed.latest({'type': 'url', 'url': self.srv.url + f'm-{ver}.json'})
rel.prerelease = pre
return rel
def context(self):
"""The launcher starting (again) in the installed folder: its settings, its log."""
ctx = Context(self.layout)
self.addCleanup(lambda: ctx._file and ctx._file.close())
return ctx
def background(self, ctx):
return jobs.BackgroundUpdate(ctx, lambda args: self.restarts.append(args) or True)
def stage(self, bg, rel):
"""poll() as the window's timer calls it: start the staging, wait for it, and return how it ended."""
self.assertEqual(bg.poll(rel), 'started')
deadline = time.time() + 60
while bg.staging() and time.time() < deadline:
time.sleep(0.02)
return bg.poll(rel)
def state(self):
return updater.current(self.layout), updater.previous(self.layout), self.restarts
def test_staging_leaves_the_running_version(self):
"""The release is unpacked beside the running version, its Python in its own folder rather than python.next
(which the stub would swap in at the next start, under the running version), and nothing is switched or
restarted."""
bg = self.background(self.context())
rel = self.publish('0.5.0', runtime='py-2')
self.assertEqual(self.stage(bg, rel), 'staged')
folder = self.layout.apps / '0.5.0'
self.assertTrue((folder / 'launcher' / 'start.pyw').is_file())
self.assertEqual(updater.read_text(folder / updater.STAGED_PYTHON / 'runtime-id.txt'), 'py-2')
self.assertFalse((self.layout.root / 'python.next').exists())
self.assertEqual(self.state(), ('0.4.0', '', []))
self.assertEqual(bg.ready(rel)['version'], '0.5.0')
self.assertIsNone(bg.poll(rel)) # once per check
bg.checked()
self.assertIsNone(bg.poll(rel)) # the next check finds it staged: nothing to download
self.assertFalse(bg.staging())
older = feed.Release({'format': 1, 'version': '0.4.5', 'app': {}})
self.assertIsNone(bg.ready(older)) # the feed no longer offers it: not installed
updater.prune(self.layout) # an update's cleanup keeps a newer staged release
self.assertTrue((folder / updater.STAGED).is_file())
def test_ready_marker(self):
"""Only a release whose staging finished (staged.json, written last) and that is still whole installs."""
rel = self.publish('0.5.0', runtime='py-2')
updater.stage_app(self.layout, rel) # unpacked, as an interrupted staging leaves it
self.assertIsNone(updater.staged(self.layout))
with self.assertRaises(updater.UpdateError):
updater.install_staged(self.layout, '0.5.0')
self.assertEqual(updater.current(self.layout), '0.4.0')
updater.stage(self.layout, rel)
self.assertEqual(updater.staged(self.layout)['version'], '0.5.0')
self.assertIsNone(updater.staged(self.layout, skip='0.5.0')) # the version that failed to start
py = self.layout.apps / '0.5.0' / updater.STAGED_PYTHON
shutil.move(py, self.tmp / 'py') # the Python it needs is gone
self.assertIsNone(updater.staged(self.layout))
shutil.move(self.tmp / 'py', py)
self.assertIsNotNone(updater.staged(self.layout))
(self.layout.apps / '0.5.0' / updater.STAGED).write_text('{"version": "0.') # cut short
self.assertIsNone(updater.staged(self.layout))
updater.stage(self.layout, rel)
# a pre-release counts only on the Preview channel; staging it removes the staged release it supersedes
updater.stage(self.layout, self.publish('0.6.0-rc1', pre=True))
self.assertIsNone(updater.staged(self.layout, 'stable'))
self.assertEqual(updater.staged(self.layout, 'preview')['version'], '0.6.0-rc1')
self.assertFalse((self.layout.apps / '0.5.0').exists())
self.assertTrue((self.layout.apps / '0.4.0').is_dir())
def test_staged_version_installs_after_a_restart(self):
"""A staged release outlives the launcher: the next start finds it without a check and installs it: its
Python to python.next for the stub, current.txt, and the restart that finishes in ContinueUpdateJob."""
bg = self.background(self.context())
rel = self.publish('0.5.0', runtime='py-2')
self.assertEqual(self.stage(bg, rel), 'staged')
bg = self.background(self.context()) # the launcher started again, still 0.4.0
self.assertEqual(bg.ready()['version'], '0.5.0')
self.assertEqual(bg.install(self.game, why='the launcher started'), '0.5.0')
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
self.assertTrue(bg.restarting)
self.assertEqual(updater.read_text(self.layout.root / 'python.next' / 'runtime-id.txt'), 'py-2')
self.assertFalse((self.layout.apps / '0.5.0' / updater.STAGED).exists())
# the stub rolled back from it: it is neither staged nor installed by itself again
self.assertTrue(updater.rollback(self.layout))
ctx = self.context()
ctx.settings['rolled_back'] = '0.5.0'
bg = self.background(ctx)
self.assertIsNone(bg.poll(rel))
self.assertEqual(bg.install(self.game, rel), '')
self.assertEqual(len(self.restarts), 1)
def test_no_install_while_the_game_runs(self):
"""A copy of the game started elsewhere, the copy the launcher started, and a download under way each hold
the install back, also one the player asked for."""
bg = self.background(self.context())
rel = self.publish('0.5.0')
self.assertEqual(self.stage(bg, rel), 'staged')
self.running = True
self.assertEqual(bg.install(self.game, rel, why='the launcher started'), '')
self.assertTrue(bg.waiting)
self.assertEqual(bg.install(self.game, rel, asked=True), '')
self.running = False
self.assertEqual(bg.install(self.game, rel, running=True), '')
bg.job = jobs.StageJob(bg.ctx, feed.Release({'format': 1, 'version': '0.5.1', 'app': {}})) # not finished
self.assertEqual(bg.install(self.game, rel), '')
self.assertEqual(self.state(), ('0.4.0', '', []))
self.assertTrue((self.layout.apps / '0.5.0' / updater.STAGED).is_file())
def test_install_after_the_game_exits(self):
"""Install Updates Automatically on (the default): a release staged during play installs once the game has
exited."""
ctx = self.context()
self.assertTrue(ctx.settings['auto_install'])
bg = self.background(ctx)
self.running = True # the check during play finds the release
rel = self.publish('0.5.0')
self.assertEqual(self.stage(bg, rel), 'staged')
self.assertEqual(bg.install(self.game, rel, why='the download finished'), '')
self.assertEqual(self.state(), ('0.4.0', '', []))
self.running = False # the game exits
self.assertEqual(bg.install(self.game, rel, why='the game exited'), '0.5.0')
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
self.assertFalse(bg.waiting)
def test_nothing_automatic_when_the_setting_is_off(self):
"""Install Updates Automatically off: the release is still staged in the background, and only selecting
Updates installs it (also once selected during the download)."""
ctx = self.context()
ctx.settings['auto_install'] = False
bg = self.background(ctx)
rel = self.publish('0.5.0')
self.assertEqual(self.stage(bg, rel), 'staged')
for why in ('the launcher started', 'the game exited', 'the game closed', 'the download finished'):
self.assertEqual(bg.install(self.game, rel, why=why), '')
self.assertFalse(bg.waiting)
self.assertEqual(self.state(), ('0.4.0', '', []))
bg.asked = True # selected while it downloaded
self.assertEqual(bg.install(self.game, rel, why='the download finished'), '0.5.0')
self.assertEqual(self.state(), ('0.5.0', '0.4.0', [['--continue-update', '0.4.0']]))
def test_failed_download_waits_for_the_next_check(self):
"""A staging that fails is logged, never raised, leaves nothing to install, and is tried again only after the
next update check."""
ctx = self.context()
bg = self.background(ctx)
rel = self.publish('0.5.0', damaged=True)
self.assertEqual(self.stage(bg, rel), 'failed')
self.assertIn('0.5.0 was not staged', (self.layout.logs / 'launcher.log').read_text(encoding='utf-8'))
self.assertIsNone(bg.poll(rel))
self.assertIsNone(bg.ready(rel))
self.assertEqual(bg.install(self.game, rel), '')
bg.checked()
self.assertEqual(self.stage(bg, rel), 'failed')
self.assertEqual(self.state(), ('0.4.0', '', []))
# ---------------------------------------------------------------- gamefiles
def sarc(files):
@@ -1800,7 +2007,7 @@ class WindowTest(Tmp):
r = subprocess.run([sys.executable, '-m', 'launcher', '--root', str(self.tmp / 'w'), '--selftest'],
cwd=ROOT, capture_output=True, text=True, timeout=180)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn('10 screens drawn', r.stdout)
self.assertIn('11 screens drawn', r.stdout)
if __name__ == '__main__':