mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
@@ -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,
|
||||
|
||||
132
launcher/jobs.py
132
launcher/jobs.py
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in New Issue
Block a user