Files
NX2navi/launcher/cli.py
chrg6 73d6c8ffb8 Refuse the command line's update while the game runs, as the window's Updates row does.
`launcher.cli update` downloaded the release and switched to it without asking whether the game ran. The new version's continue-update then failed in its rebuild with "the game is running", which left the new launcher with the old build until Rebuild the Game. The window's App.start_update refuses first; the command now does the same before its update check, and ends with exit code 1 and "error: the game is running: close it first, the update rebuilds it".

Not reproduced before the change: found by reading the code. Checks that passed: tests/launcher_test.py (217 tests, a new one in which a running game stops `update` before its check and its download, which fails before the change) and tools/check_content.py --staged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:43:30 -05:00

158 lines
7.0 KiB
Python

"""The launcher without its window, for scripts, tests and anyone who prefers a console:
python -m launcher.cli check is there a newer release?
python -m launcher.cli update download and stage it, then finish in the new version
python -m launcher.cli tools install the build tools and zbic
python -m launcher.cli install --dump F --game D build the game (the window's Begin Installation)
python -m launcher.cli rebuild [--game D] rebuild after an update (the window's Rebuild the Game)
python -m launcher.cli fonts [--dump F] decode the game's font for the window
python -m launcher.cli continue-update OLD what the new version runs after `update`
In an installed launcher: python\\python.exe app\\<version>\\launcher\\cli.py <command> (this file puts the release
folder on sys.path itself, as start.pyw does). Exit code 0 on success, 1 on failure, 3 when cancelled.
"""
import argparse
import os
import subprocess
import sys
import time
if __name__ == '__main__' and not __package__:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
__package__ = 'launcher' # noqa: A001 — lets the relative imports below work when run as a script
from . import build, gamefiles, jobs, locks, versions # noqa: E402
from .context import Context # noqa: E402
from .paths import Layout # noqa: E402
def follow(job):
"""Run a job here, printing each step's state as it changes."""
seen = {}
job.start()
while job.result is None:
with job.locked(): # each step as one change of the worker left it
steps = [(s.id, s.label, s.state, s.value, s.fraction, s.detail) for s in job.steps]
for sid, label, state, value, fraction, detail in steps:
key = (state, value if state != jobs.RUNNING else int(fraction * 20))
if seen.get(sid) != key:
seen[sid] = key
print(f'{label}: {value}' + (f' ({detail})' if detail and state == jobs.RUNNING else ''), flush=True)
time.sleep(0.2)
for s in job.steps:
if seen.get(s.id, (None,))[0] != s.state:
print(f'{s.label}: {s.value}', flush=True)
if job.result == 'failed':
print(f'error: {job.error}', file=sys.stderr, flush=True)
return {'ok': 0, 'failed': 1, 'cancelled': 3}[job.result]
def main(argv=None):
ap = argparse.ArgumentParser(prog='launcher.cli', description=__doc__.splitlines()[0])
ap.add_argument('--root', help='the launcher folder (default: this release\'s, or build/launcher-dev)')
sub = ap.add_subparsers(dest='cmd', required=True)
sub.add_parser('check')
sub.add_parser('update')
sub.add_parser('tools')
p = sub.add_parser('install')
p.add_argument('--dump', required=True)
p.add_argument('--game', required=True)
p = sub.add_parser('rebuild')
p.add_argument('--game')
p = sub.add_parser('fonts')
p.add_argument('--dump', default='')
p.add_argument('--game', default='')
p = sub.add_parser('continue-update')
p.add_argument('old')
a = ap.parse_args(argv)
ctx = Context(Layout(root=a.root) if a.root else None, echo=False)
if a.cmd == 'check': # reads the feed and changes nothing: it runs beside an open launcher
return run(a, ctx, None)
# Every other command changes the launcher's folder or the game's, as the window's jobs do: it holds the folder's
# lock (launcher/locks.py) from before it writes the settings until it ends, and is refused while the window or
# another command holds it
try:
held = locks.FileLock(ctx.layout.instance_lock, f'"{a.cmd}" from the command line', where=ctx.layout.root)
except locks.Busy as e:
print(f'error: {e}. Close it, or wait until it is done, and try again.', file=sys.stderr, flush=True)
return 1
try:
return run(a, ctx, held)
finally:
held.release()
def run(a, ctx, held):
"""One command. held: the launcher folder's lock, which `update` lets go of before the new version takes over."""
s = ctx.settings
if a.cmd == 'check':
job = jobs.CheckJob(ctx).run_inline()
if job.result != 'ok':
print(f'error: {job.error}', file=sys.stderr)
return 1
r = job.release
if not r:
print('no release found')
elif jobs.update_available(ctx, r):
print(f'update available: {versions.display(r.version)} (you have {ctx.version})')
else:
print(f'up to date: {ctx.version} (newest release {versions.display(r.version)})')
return 0
if a.cmd == 'update':
# The new version rebuilds the game (ContinueUpdateJob), which cannot replace a running exe: refused here
# before anything is downloaded, as the window's Updates row refuses (ui/app.py App.start_update)
gd = s.get('game_dir')
if gd and build.game_running(gd):
print('error: the game is running: close it first, the update rebuilds it', file=sys.stderr)
return 1
check = jobs.CheckJob(ctx).run_inline()
if check.result != 'ok' or not jobs.update_available(ctx, check.release):
print(check.error or f'up to date: {ctx.version}')
return 0 if check.result == 'ok' else 1
job = jobs.UpdateDownloadJob(ctx, check.release)
code = follow(job)
if code or job.after != 'restart':
return code
new = ctx.layout.apps / versions.display(check.release.version)
cli = new / 'launcher' / 'cli.py'
print(f'finishing in {versions.display(check.release.version)}', flush=True)
held.release() # the new version's command takes the lock itself
return subprocess.call([ctx.layout.python_for_builds(), str(cli), '--root', str(ctx.layout.root),
'continue-update', ctx.version])
if a.cmd == 'continue-update':
gd = s.get('game_dir')
return follow(jobs.ContinueUpdateJob(ctx, gd if build.installed(gd) else '', a.old,
s.get('dump')))
if a.cmd == 'tools':
job = jobs.RebuildJob(ctx, '')
job.steps = job.steps[:1]
job.run = lambda: job.ensure_tools('tools_update')
return follow(job)
if a.cmd == 'install':
s['dump'], s['game_dir'] = os.path.abspath(a.dump), os.path.abspath(a.game)
s.save()
return follow(jobs.InstallJob(ctx, s['dump'], s['game_dir']))
if a.cmd == 'rebuild':
gd = os.path.abspath(a.game) if a.game else s.get('game_dir')
if not gd:
print('error: no game folder (give --game)', file=sys.stderr)
return 1
return follow(jobs.RebuildJob(ctx, gd, s.get('dump')))
if a.cmd == 'fonts':
got = gamefiles.extract_fonts(ctx.layout, a.game or s.get('game_dir'), a.dump or s.get('dump'))
for role, (path, fam) in got.items():
print(f'{role}: {fam} ({path})')
return 0 if got else 1
return 2
if __name__ == '__main__':
sys.exit(main())