Files
NX2navi/launcher/ui/app.py
Claude c403547c8b Add the NX2navi launcher: a one-click setup that builds the game from the player's dump, starts it and keeps it up to date.
NX2navi-Setup.exe installs the launcher into %LOCALAPPDATA%\Programs\NX2navi without administrator rights, with
shortcuts and an Apps entry. The launcher finds the player's .nup, downloads the build tools itself (the MSYS2 UCRT64
packages toolchain.lock.json pins, checked by sha256 and unpacked without pacman, and zbic from PyPI) and runs
tools/make_game.py, whose steps it shows with their progress. Its window follows the game's options menu: colours
sampled from it, the menu's filigree, the white glowing cursor, the Triforce in the title band and as the programs'
icon, and all text in the game's Cetra Display, decoded from the player's own dump. Mouse, keyboard and an XInput
pad drive it.

Updates come from GitHub releases (NX2navi-update.json): the new version is downloaded, checked and staged beside
the running one, the stub restarts into it and falls back to the previous version when it fails to start, and the
game is rebuilt without the dump. make_game.py now keys its exefs and romfs steps on the game files alone, has
--rebuild and --events, compiles only the files whose source, headers, flags or compiler changed, and replaces
ocarina.exe only once the new one links. tools/make_launcher.py builds the release on Windows or Linux.

Tested: tests/launcher_test.py (19 tests), the real toolchain download and install, and under Wine 11 the setup,
the stub, the window on the bundled Windows Python, an update from a local feed by clicking Updates, the rollback
of a version that fails to start, and the downloaded clang compiling and linking a program against the game's
libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Merged onto the current port: make_game.py keeps the launcher's step keys, cached link and in-place swap, and its
own runtime manifest (windres), AMD's FidelityFX library and the shader preparation, which becomes a step of its own
("shaders") that the launcher shows in its install, rebuild and update jobs. make_launcher.py ships the shader tools
and, with --ffx, AMD's library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:50:52 -05:00

1298 lines
58 KiB
Python

"""The launcher's window, laid out like the game's options menu (docs/launcher.md, "Look"): a wooden title band with
the Triforce and the name in the game's Cetra Display, a list of rows on the left (a bronze label cell and a dark value cell each), a white
glowing cursor on the selected row, filigree dividers between groups and a description box on the right that
explains the selected row.
The window is one Tk canvas at 1000 x 640 logical pixels, scaled to the display. The pictures come from art.py; text,
flat cells and the orange choices are canvas items. Screens:
first_run the dump, the install folder, three choices and Begin Installation
home Play, Updates, the game folder, Rebuild the Game, Settings
job the steps of an install, update or rebuild, with their progress, and Cancel; after a failure Try Again
and Copy Report
settings the launcher's choices, the folders, the logs and Uninstall
uninstall what to remove, then Uninstall or Cancel
Mouse, keyboard (arrows, Enter or Space, Esc, F5) and an XInput gamepad (d-pad or stick, A, B, Y) all move the cursor.
"""
import argparse
import os
import shutil
import sys
import time
import tkinter as tk
import tkinter.font as tkfont
from pathlib import Path
from tkinter import filedialog
from .. import NAME, build, feed, gamefiles, jobs, versions, winsys
from ..context import Context
from . import art
W, H = 1000, 640
BAND = (52, 948, 56) # x0, x1, height
PANEL = (112, 60, 580) # x0, top, x1
ROWS = (140, 552, 96) # x0, x1, top
LABEL_W = 222 # 54 % of the row
GAP = 12
DESC = (614, 336) # x, width
P = art.PALETTE
class Row:
def __init__(self, kind, label='', value='', style='normal', big=False, icon=None, badge=False, desc=None,
on=None, left=None, right=None, get=None, options=None, fraction=0.0, actions=None, check=False,
focus_value=False, height=None, enabled=True):
self.kind, self.label, self.value, self.style = kind, label, value, style
self.big, self.icon, self.badge, self.desc, self.check = big, icon, badge, desc, check
self.on, self.left, self.right, self.get, self.options = on, left, right, get, options
self.fraction, self.actions, self.focus_value, self.enabled = fraction, actions or [], focus_value, enabled
default_h = {'divider': 26, 'action': 54, 'pair': 44}.get(kind, 64 if big else 40)
self.h = height or default_h
self.rect = self.value_rect = None
self.parts = [] # pair: [(rect, label, callback)]
@property
def focusable(self):
return self.kind != 'divider'
def fit(font, text, width):
"""text shortened in the middle with an ellipsis so it fits width pixels."""
if font.measure(text) <= width:
return text
lo, hi = 1, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
keep = mid // 2
cand = text[:keep] + '…' + text[len(text) - (mid - keep):]
if font.measure(cand) <= width:
lo = mid
else:
hi = mid - 1
keep = lo // 2
return text[:keep] + '…' + text[len(text) - (lo - keep):]
def free_gb(path):
p = Path(path)
while not p.exists() and p.parent != p:
p = p.parent
try:
return shutil.disk_usage(p).free / 1e9
except OSError:
return 0.0
def drive_of(path):
return Path(path).anchor.rstrip('\\/') or str(Path(path).parent)
class App:
def __init__(self, root, ctx, args):
self.root, self.ctx, self.args = root, ctx, args
self.layout, self.settings = ctx.layout, ctx.settings
self.s = self.compute_scale()
self.sprites = {}
self.photos = {}
self.rows, self.focus = [], 0
self.screen = None
self.job = None
self.job_rev = -1
self.job_return = 'home'
self.check = None
self.release = None
self.check_error = ''
self.checked_at = 0.0
self.message = ''
self.dumps = None
self.dump_search = None
self.uninstall_game = False
self.game_proc = None
self.cursor_frames, self.cursor_k = [], 0
self.last_second = 0
self.gamepad = winsys.Gamepad()
root.title(NAME)
root.resizable(False, False)
root.configure(background=P['window'])
self.canvas = tk.Canvas(root, width=self.X(W), height=self.X(H), highlightthickness=0, bd=0,
background=P['window'], cursor='arrow')
self.canvas.pack()
self.set_icon()
self.load_fonts()
self.draw_static()
self.bind_input()
self.start()
self.root.after(80, self.animate)
self.root.after(150, self.tick)
self.root.after(50, self.poll_gamepad)
self.center()
# ---------------------------------------------------------------- scale, icon, fonts
def compute_scale(self):
try:
s = self.root.winfo_fpixels('1i') / 96.0
except tk.TclError:
s = 1.0
sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight()
s = min(max(s, 1.0), sw * 0.94 / W, sh * 0.88 / H)
return max(0.75, round(s * 4) / 4)
def X(self, v):
return int(round(v * self.s))
def center(self):
self.root.update_idletasks()
w, h = self.X(W), self.X(H)
x = max(0, (self.root.winfo_screenwidth() - w) // 2)
y = max(0, (self.root.winfo_screenheight() - h) // 3)
self.root.geometry(f'{w}x{h}+{x}+{y}')
def set_icon(self):
try:
self.photos['icon'] = art.photo(self.root, art.triforce(64))
self.root.iconphoto(True, self.photos['icon'])
if os.name == 'nt':
ico = self.layout.cache / 'nx2navi.ico'
if not ico.is_file():
ico.write_bytes(art.triforce_ico())
self.root.iconbitmap(default=str(ico))
except (tk.TclError, OSError) as e:
self.ctx.log(f'icon: {e}')
def load_fonts(self):
"""The game's Cetra Display when this PC has decoded it from the player's game (data/cache/fonts), else the
system's interface font."""
fams = {}
for role, (path, family) in gamefiles.cached_fonts(self.layout).items():
if winsys.register_font(path):
fams[role] = family
available = set(tkfont.families(self.root))
fallback = next((f for f in ('Segoe UI', 'Source Sans 3', 'Noto Sans', 'DejaVu Sans', 'Helvetica')
if f in available), 'TkDefaultFont')
# Every text is in the game's Cetra Display; only the build log keeps a monospaced face so its lines align.
text = title = fams.get('title') if fams.get('title') in available else fallback
mono = next((f for f in ('Consolas', 'Cascadia Mono', 'DejaVu Sans Mono', 'Courier New') if f in available),
'TkFixedFont')
self.game_fonts = bool(fams.get('title') and fams['title'] in available)
self.ctx.log(f'fonts: {text}' + (' (the game\'s, from data/cache/fonts)' if self.game_fonts else
' (the game\'s Cetra Display is not decoded yet)'))
self.fspec = {}
def F(family, px, weight='normal'):
font = tkfont.Font(root=self.root, family=family, size=-max(8, int(round(px * self.s))), weight=weight)
self.fspec[id(font)] = (family, max(8, int(round(px * self.s))), weight)
return font
self.fcache = {}
self.f = {'band': F(title, 25), 'big': F(text, 23), 'big_small': F(text, 21), 'label': F(text, 17),
'value': F(text, 16), 'desc': F(text, 17), 'small': F(text, 15), 'heading': F(title, 21),
'sub': F(title, 17), 'action': F(text, 18), 'status': F(text, 15), 'mono': F(mono, 11.5),
'badge': F(text, 15, 'bold'), 'action_big': F(text, 21)}
def fit_font(self, font, text, width, smallest=0.72):
"""font, or the same face a few pixels smaller so text fits width (down to smallest of its size)."""
if font.measure(text) <= width:
return font
family, px, weight = self.fspec[id(font)]
for size in range(px - 1, max(7, int(px * smallest)) - 1, -1):
key = (family, size, weight)
if key not in self.fcache:
self.fcache[key] = tkfont.Font(root=self.root, family=family, size=-size, weight=weight)
if self.fcache[key].measure(text) <= width:
return self.fcache[key]
return self.fcache[(family, max(7, int(px * smallest)), weight)]
def fonts_from_game(self, game_dir='', dump=''):
"""Decode the game's fonts in the background and redraw with them."""
if self.game_fonts:
return
def work():
try:
got = gamefiles.extract_fonts(self.layout, game_dir, dump)
except Exception as e:
self.ctx.log(f'fonts: {e}')
got = {}
if got:
self.root.after(0, self.reload_fonts)
import threading
threading.Thread(target=work, daemon=True).start()
def reload_fonts(self):
self.load_fonts()
self.draw_static()
self.render()
# ---------------------------------------------------------------- pictures
def sprite(self, key, make):
if key not in self.sprites:
self.sprites[key] = art.photo(self.root, make())
return self.sprites[key]
def draw_static(self):
c, s = self.canvas, self.s
c.delete('static')
c.create_image(0, 0, anchor='nw', image=self.sprite('backdrop', lambda: art.backdrop(self.X(W), self.X(H), s)),
tags='static')
bx0, bx1, bh = BAND
c.create_image(self.X(bx0), 0, anchor='nw', tags='static',
image=self.sprite('band', lambda: art.band(self.X(bx1) - self.X(bx0), self.X(bh), s)))
px0, ptop, px1 = PANEL
c.create_image(self.X(px0), self.X(ptop), anchor='nw', tags='static',
image=self.sprite('panel', lambda: art.panel(self.X(px1) - self.X(px0), self.X(H - ptop), s)))
tri = self.sprite('triforce', lambda: art.triforce(self.X(44)))
title_w = self.f['band'].measure(NAME)
gap = self.X(12)
total = self.X(44) + gap + title_w
x = self.X(W / 2) - total // 2
c.create_image(x, self.X(bh / 2), anchor='w', image=tri, tags='static')
tx = x + self.X(44) + gap
c.create_text(tx + 1, self.X(bh / 2) + self.X(2), text=NAME, anchor='w', font=self.f['band'], fill='#1a120a',
tags='static')
c.create_text(tx, self.X(bh / 2), text=NAME, anchor='w', font=self.f['band'], fill=P['band_text'],
tags='static')
c.tag_lower('static')
# ---------------------------------------------------------------- input
def bind_input(self):
r = self.root
for key, fn in (('<Up>', lambda e: self.move(-1)), ('<Down>', lambda e: self.move(1)),
('<Left>', lambda e: self.side(-1)), ('<Right>', lambda e: self.side(1)),
('<Return>', lambda e: self.activate()), ('<KP_Enter>', lambda e: self.activate()),
('<space>', lambda e: self.activate()), ('<Escape>', lambda e: self.back()),
('<F5>', lambda e: self.check_now(force=True))):
r.bind(key, fn)
self.canvas.bind('<Motion>', self.on_motion)
self.canvas.bind('<Button-1>', self.on_click)
r.protocol('WM_DELETE_WINDOW', self.on_close)
def poll_gamepad(self):
try:
for b in self.gamepad.poll():
{'up': lambda: self.move(-1), 'down': lambda: self.move(1), 'left': lambda: self.side(-1),
'right': lambda: self.side(1), 'a': self.activate, 'b': self.back,
'y': lambda: self.check_now(force=True)}.get(b, lambda: None)()
except Exception as e:
self.ctx.log(f'gamepad: {e}')
self.gamepad.dll = None
self.root.after(50, self.poll_gamepad)
def focusables(self):
return [i for i, r in enumerate(self.rows) if r.focusable]
def move(self, d):
idx = self.focusables()
if not idx:
return
pos = idx.index(self.focus) if self.focus in idx else 0
self.focus = idx[max(0, min(len(idx) - 1, pos + d))]
self.render()
def focused(self):
return self.rows[self.focus] if 0 <= self.focus < len(self.rows) else None
def side(self, d):
r = self.focused()
if not r:
return
if r.kind == 'toggle':
r.on(d < 0) # left = On, right = Off
elif r.kind == 'enum':
(r.left if d < 0 else r.right)()
elif r.kind == 'pair':
r.pick = max(0, min(len(r.actions) - 1, getattr(r, 'pick', 0) + d))
else:
return
self.render()
def activate(self):
r = self.focused()
if not r or not r.enabled:
return
if r.kind == 'toggle':
r.on(not r.get())
elif r.kind == 'enum':
r.right()
elif r.kind == 'pair':
r.actions[getattr(r, 'pick', 0)][1]()
return
elif r.on:
r.on()
return
self.render()
def back(self):
if self.screen == 'settings':
self.show('home')
elif self.screen == 'uninstall':
self.show('settings')
elif self.screen == 'job' and self.job and self.job.result is None:
self.cancel_job()
elif self.screen == 'job':
self.leave_job()
def hit(self, x, y):
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):
return i
return None
def on_motion(self, e):
i = self.hit(e.x, e.y)
if i is not None and i != self.focus:
self.focus = i
r = self.rows[i]
if r.kind == 'pair':
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
self.render()
elif i is not None and self.rows[i].kind == 'pair':
r = self.rows[i]
pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
if pick != getattr(r, 'pick', 0):
r.pick = pick
self.render()
def on_click(self, e):
i = self.hit(e.x, e.y)
if i is None:
return
self.focus = i
r = self.rows[i]
if not r.enabled:
self.render()
return
if r.kind == 'toggle' and r.value_rect:
x0, _, x1, _ = r.value_rect
r.on(e.x < (x0 + x1) / 2)
self.render()
elif r.kind == 'enum' and r.value_rect:
x0, _, x1, _ = r.value_rect
(r.left if e.x < x0 + (x1 - x0) / 3 else r.right)()
self.render()
elif r.kind == 'pair':
r.pick = 0 if e.x < (r.rect[0] + r.rect[2]) / 2 else 1
r.actions[r.pick][1]()
else:
self.activate()
def on_close(self):
if self.job and self.job.result is None:
self.job.cancel()
self.root.destroy()
# ---------------------------------------------------------------- screens
def start(self):
a = self.args
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}')
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'):
self.fonts_from_game(gd, self.settings.get('dump'))
if a.uninstall:
self.show('uninstall')
elif a.continue_update is not None:
self.run_job(jobs.ContinueUpdateJob(self.ctx, gd if gd and build.built_version(gd) else '',
a.continue_update, self.settings.get('dump')), 'home')
elif gd and build.built_version(gd):
self.show('home')
self.after_update_notice()
if self.settings.get('auto_check'):
self.check_now()
else:
self.show('first_run')
self.find_dumps()
def after_update_notice(self):
if self.args.rolled_back:
self.message = (f'Version {self.args.rolled_back} did not start, so NX2navi went back to '
f'{versions.display(self.layout.version)}.')
def show(self, screen, focus=None):
self.screen = screen
self.rows = getattr(self, 'rows_' + screen)()
idx = self.focusables()
self.focus = focus if focus is not None and focus in idx else (idx[0] if idx else 0)
if screen == 'job' and focus is None:
self.focus = self.job_focus()
self.render()
# ---- first run
def find_dumps(self):
import threading
extra = [self.args.source] if self.args.source else []
extra.append(str(self.layout.root.parent))
def work():
try:
found = gamefiles.find_dumps(self.layout, extra)
except Exception as e:
self.ctx.log(f'dump search: {e}')
found = []
self.root.after(0, lambda: self.dumps_found(found))
self.dump_search = threading.Thread(target=work, daemon=True)
self.dump_search.start()
def dumps_found(self, found):
self.dumps = found
if found and not self.settings.get('dump'):
self.settings['dump'] = str(found[0])
self.settings.save()
self.fonts_from_game('', str(found[0]))
if self.screen == 'first_run':
self.show('first_run', self.focus)
def game_dir(self):
return self.settings.get('game_dir') or str(self.layout.default_game_dir())
def rows_first_run(self):
dump = self.settings.get('dump')
if dump:
dump_value, dump_style, check = Path(dump).name, 'normal', True
where = str(Path(dump).parent)
dump_desc = (f'Your dump: {Path(dump).name}, in {where}. Select this row to choose another .nup file.',)
elif self.dumps is None:
dump_value, dump_style, check = 'Looking for your dump…', 'dim', False
dump_desc = ('Looking for your dump of Ocarina of Time in Downloads, Desktop, Documents and the folder '
'the setup ran from.',)
else:
dump_value, dump_style, check = 'Choose your .nup file', 'salmon', False
dump_desc = ('Choose your own dump of Ocarina of Time with the 1.1.1 update, as one .nup file. None was '
'found in Downloads, Desktop or Documents.',)
gd = self.game_dir()
need = 30
free = free_gb(gd)
folder_desc = (f'The game is built into this folder. {free:.0f} GB free on {drive_of(gd)}; the game needs '
f'about {need} GB. Select this row to choose another folder.',)
ready = bool(dump)
begin_desc = (f'Build Ocarina of Time on this PC from your own dump. It takes 10 to 25 minutes, once, and '
f'downloads 170 MB of build tools. The game needs about {need} GB on {drive_of(gd)} '
f'({free:.0f} GB free).',) if ready else \
('Choose your dump first: select Game Dump above.',)
return [
Row('value', 'Game Dump', dump_value, dump_style, big=True, check=check, on=self.choose_dump,
desc=dump_desc),
Row('value', 'Install Folder', gd, big=True, on=self.choose_folder, desc=folder_desc),
Row('divider'),
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.'),
self.channel_row(),
Row('divider'),
Row('action', 'Begin Installation', on=self.begin_install, desc=begin_desc, enabled=True),
]
def toggle(self, label, key, desc):
def on(v):
self.settings[key] = bool(v)
self.settings.save()
if key == 'desktop_shortcut':
winsys.desktop_shortcut(self.layout, bool(v))
return Row('toggle', label, on=on, get=lambda: bool(self.settings.get(key)), desc=(desc,))
def channel_row(self):
def flip():
self.settings['channel'] = 'preview' if self.settings.get('channel') == 'stable' else 'stable'
self.settings.save()
self.release, self.checked_at = None, 0
return Row('enum', 'Update Channel', options=('Stable', 'Preview'),
get=lambda: 'Preview' if self.settings.get('channel') == 'preview' else 'Stable',
left=flip, right=flip,
desc=('Stable gets each finished release. Preview also gets test builds before they are '
'released, which may have new problems.',))
def choose_dump(self):
start = str(Path(self.settings.get('dump')).parent) if self.settings.get('dump') else str(Path.home())
path = filedialog.askopenfilename(parent=self.root, title='Choose your Ocarina of Time dump',
initialdir=start, filetypes=[('Game dump', '*.nup'), ('All files', '*.*')])
if not path:
return
if not gamefiles.is_game_nup(self.layout, path):
self.message = f'{Path(path).name} is not a dump of Ocarina of Time.'
self.render()
return
self.settings['dump'] = path
self.settings.save()
self.message = ''
self.fonts_from_game('', path)
self.show(self.screen, self.focus)
def choose_folder(self):
path = filedialog.askdirectory(parent=self.root, title='Choose the folder for the game',
initialdir=str(Path(self.game_dir()).parent))
if not path:
return
p = Path(path)
if p.exists() and any(p.iterdir()) and not (p / 'build-cache').is_dir() and p.name.lower() != 'nx2navi':
p = p / 'NX2navi'
self.settings['game_dir'] = str(p)
self.settings.save()
self.show(self.screen, self.focus)
def begin_install(self):
dump = self.settings.get('dump')
if not dump:
self.focus = 0
self.choose_dump()
return
gd = self.game_dir()
if free_gb(gd) < 31 and not (Path(gd) / 'romfs' / 'romfs.bin').is_file():
self.message = f'{drive_of(gd)} has {free_gb(gd):.0f} GB free; the game needs about 30 GB.'
self.render()
return
self.settings['game_dir'] = gd
self.settings.save()
if self.settings.get('desktop_shortcut'):
winsys.desktop_shortcut(self.layout, True)
self.run_job(jobs.InstallJob(self.ctx, dump, gd), 'home', back='first_run')
# ---- home
def rows_home(self):
gd = self.settings.get('game_dir')
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):
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)
elif self.check and self.check.result is None:
upd = Row('value', 'Updates', 'Checking…', 'dim', big=True, icon='refresh', on=lambda: None,
desc=('Looking for a new version of NX2navi.',))
elif self.check_error:
upd = Row('value', 'Updates', 'Could not check', 'dim', big=True, icon='refresh',
on=lambda: self.check_now(force=True),
desc=(f'The update check failed: {self.check_error}', 'Select this row to try again.'))
elif self.checked_at:
upd = Row('value', 'Updates', f'Up to Date · {mine}', 'normal', big=True, icon='refresh', check=True,
on=lambda: self.check_now(force=True),
desc=(f'{mine} is the newest version. Checked {self.ago(self.checked_at)}.',
'Select this row to check again.'))
else:
upd = Row('value', 'Updates', f'Version {mine}', 'normal', big=True, icon='refresh',
on=lambda: self.check_now(force=True),
desc=(f'You have NX2navi {mine}. Select this row to look for a new version.',))
rebuild_value = f'Built with {versions.display(built)}' if built else 'Not built'
rebuild_style = 'normal' if versions.display(built) == mine else 'salmon'
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 self.message:
play_desc = (self.message,) + play_desc
return [
Row('value', 'Play', play_value, big=True, icon='play', on=self.play, desc=play_desc),
upd,
Row('divider'),
Row('value', 'Game Folder', gd, on=lambda: self.open(gd),
desc=('Open the game folder. Your saves are in its profile folder, the game\'s settings in '
'nx2navi.ini and its logs in logs.',)),
Row('value', 'Rebuild the Game', rebuild_value, rebuild_style, on=self.start_rebuild,
desc=('Build the game again from the files of the last build. Only what changed is compiled. Use it '
'after changing build tools, or if the game stopped starting.',)),
Row('value', 'Settings', '', 'arrow', on=lambda: self.show('settings'),
desc=('Updates, the game folder, the logs and uninstalling.',)),
]
def update_desc(self):
r = self.release
nv, mine = versions.display(r.version), versions.display(self.layout.version)
notes = feed.plain_notes(r.notes, 6)
return {'title': f'Version {nv} Is Ready',
'text': [f'You have {mine}. Updating downloads the new version and rebuilds the game in a few '
f'minutes. Your saves and settings stay as they are.'],
'divider': bool(notes), 'subtitle': "What's New" if notes else '', 'bullets': notes}
def ago(self, t):
d = time.time() - t
if d < 90:
return 'just now'
if d < 3600:
return f'{int(d // 60)} minutes ago'
return time.strftime('at %H:%M', time.localtime(t))
def check_now(self, force=False):
if self.check and self.check.result is None:
return
if not force and time.time() - self.settings.get('last_check', 0) < 600 and self.release:
return
self.check_error = ''
self.check = jobs.CheckJob(self.ctx).start()
if self.screen == 'home':
self.show('home', self.focus)
def check_done(self):
c = self.check
if c.result == 'ok':
self.release = c.release
self.checked_at = time.time()
self.settings['last_check'] = self.checked_at
if self.release:
self.settings['last_seen'] = self.release.version
self.settings.save()
self.ctx.log(f'update check: {self.release.version if self.release else "no release"}')
else:
self.check_error = c.error or 'cancelled'
self.ctx.log(f'update check: {self.check_error}')
if self.screen == 'home':
self.show('home', self.focus)
def play(self):
gd = self.settings.get('game_dir')
exe = Path(gd) / 'ocarina.exe'
if not exe.is_file():
self.message = 'The game is not built yet.'
self.render()
return
if self.game_proc and self.game_proc.poll() is None:
return
try:
self.game_proc = winsys.start_game(exe)
except OSError as e:
self.message = f'The game could not start: {e}'
self.render()
return
self.ctx.log(f'play: {exe}')
if self.settings.get('hide_while_playing'):
self.root.withdraw()
self.root.after(1000, self.watch_game)
def watch_game(self):
if self.game_proc and self.game_proc.poll() is None:
self.root.after(1000, self.watch_game)
return
code = self.game_proc.returncode if self.game_proc else 0
self.game_proc = None
self.root.deiconify()
self.root.lift()
if code not in (0, None):
self.message = f'The game ended with code {code}. Its log is logs\\nx2navi.log in the game folder.'
self.show('home', self.focus)
def open(self, path):
try:
winsys.open_path(path)
except OSError as e:
self.message = str(e)
self.render()
def start_update(self):
if not self.release:
return
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
self.run_job(jobs.UpdateDownloadJob(self.ctx, self.release), 'home')
def start_rebuild(self):
gd = self.settings.get('game_dir')
self.run_job(jobs.RebuildJob(self.ctx, gd, self.settings.get('dump')), 'home')
# ---- settings
def rows_settings(self):
gd = self.settings.get('game_dir')
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.'),
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',
'Keep the translated code and compiled files (about 5 GB) in the game folder, so an update '
'compiles only what changed. Off: they are deleted after each build.'),
self.channel_row(),
Row('divider'),
Row('value', 'Game Folder', gd or 'Not chosen', on=lambda: gd and self.open(gd),
desc=('Open the game folder: saves in profile, the game\'s settings in nx2navi.ini, its logs in '
'logs.',)),
Row('value', 'Game Dump', Path(dump).name if dump else 'Not chosen', on=self.choose_dump,
desc=(f'The dump the game was built from: {dump}.' if dump else 'No dump chosen yet.',
'Select this row to choose another .nup file.')),
Row('divider'),
Row('value', 'Open Logs Folder', '', 'arrow', on=lambda: self.open(self.layout.logs),
desc=('The launcher\'s logs. Attach launcher.log when you report a problem with installing or '
'updating; never send game files.',)),
Row('value', 'Uninstall NX2navi', 'Keeps your saves', 'salmon', on=lambda: self.show('uninstall'),
desc=('Remove the launcher and its build tools. You choose on the next screen whether the built '
'game goes too.',)),
Row('action', 'Back', on=lambda: self.show('home'), height=40, desc=('Back to the main menu.',)),
]
# ---- uninstall
def rows_uninstall(self):
def on(v):
self.uninstall_game = bool(v)
gd = self.settings.get('game_dir')
return [
Row('toggle', 'Remove the Built Game', on=on, get=lambda: self.uninstall_game,
desc=(f'On: the game folder ({gd or "none"}) is deleted too, saves included.',
'Off: the game folder and your saves stay where they are.')),
Row('divider'),
Row('pair', actions=[('Uninstall', self.do_uninstall), ('Cancel', lambda: self.show('settings'))],
desc=('Uninstall removes NX2navi, its build tools and its shortcuts. Your dump is never touched.',)),
]
def do_uninstall(self):
gd = self.settings.get('game_dir') if self.uninstall_game else ''
if gd and build.game_running(gd):
self.message = 'Close the game first.'
self.render()
return
self.ctx.log(f'uninstall (game folder too: {bool(gd)})')
winsys.uninstall(self.layout, gd, self.ctx.log)
self.root.destroy()
# ---- jobs
def run_job(self, job, return_to, back=None):
self.job, self.job_return, self.job_back = job, return_to, back or return_to
self.job_rev = -1
self.message = ''
winsys.keep_awake(True)
job.start()
self.show('job')
def rows_job(self):
j = self.job
rows = []
for st in j.steps:
if st.state == jobs.RUNNING:
rows.append(Row('progress', st.label, fraction=st.fraction, value=f'{int(st.fraction * 100)}%',
focus_value=True, desc=None))
else:
style = {jobs.DONE: 'normal', jobs.SKIPPED: 'dim', jobs.PENDING: 'dim', jobs.FAILED: 'salmon'}[st.state]
rows.append(Row('value', st.label, st.value, style, check=st.state == jobs.DONE, desc=None,
focus_value=st.state == jobs.FAILED))
if st.id in ('romfs', 'verify') and st is not j.steps[-1]:
rows.append(Row('divider'))
if j.result is None:
label = {'install': 'Cancel Installation', 'update': 'Cancel Update'}.get(j.kind, 'Cancel')
rows.append(Row('action', label, on=self.cancel_job, height=44))
elif j.result == 'failed':
rows.append(Row('pair', actions=[('Try Again', self.retry_job), ('Copy Report', self.copy_report)]))
for r in rows:
r.desc = self.job_desc
return rows
def job_focus(self):
"""The cursor sits on the running step, or on Try Again after a failure."""
for i, r in enumerate(self.rows):
if self.job.result == 'failed' and r.kind == 'pair':
return i
if r.kind == 'progress':
return i
return self.focusables()[-1] if self.focusables() else 0
def job_desc(self):
j = self.job
cur = j.current()
lines = [ln for ln in j.lines if ln.strip() and not ln.startswith('@@')][-6:]
if j.result == 'failed':
return {'title': 'The Build Stopped' if j.kind != 'update' or cur.id not in ('download', 'verify')
else 'The Update Stopped',
'text': [self.friendly(j.error), 'Nothing in your game folder was changed.' if cur.id not in
('download', 'verify', 'tools', 'tools_update') else ''],
'log': lines, 'note': 'Try again first. If it stops again, use Copy Report and send it with your '
'issue. Never send game files.'}
if j.result == 'cancelled':
return {'title': 'Cancelled', 'text': ['Nothing was changed that a new start cannot finish.']}
if j.result == 'ok':
return {'title': 'Done', 'text': [f'Finished in {jobs.mmss(j.elapsed())}.']}
text = [cur.desc + (f' {cur.detail}.' if cur.detail else '')]
eta = j.eta()
return {'title': self.step_title(cur), 'text': text, 'progress': j.overall(),
'progress_label': ('Overall', self.eta_text(eta)),
'log': lines,
'note': 'Keep using your PC if you like. It stays awake until the build is done.'
if j.kind != 'update' or cur.id not in ('download', 'verify') else
'Your saves and settings stay as they are.'}
@staticmethod
def eta_text(eta):
if not eta:
return ''
minutes = max(1, int(eta // 60 + 1))
return 'about 1 minute left' if minutes == 1 else f'about {minutes} minutes left'
def step_title(self, st):
return {'tools': 'Gathering Build Tools', 'tools_update': 'Updating Build Tools', 'check': 'Inspecting Your Game',
'exefs': 'Unpacking the Program', 'romfs': 'Packing the Game Data', 'lift': 'Translating the Code',
'compile': 'Compiling', 'compile_changed': 'Compiling Changed Files', 'shaders': 'Preparing the Shaders',
'ready': 'Ready to Play',
'download': 'Downloading', 'verify': 'Verifying', 'swap': 'Swapping In the New Build'}.get(st.id,
st.label)
@staticmethod
def friendly(err):
e = (err or 'unknown error').strip()
return e[0].upper() + e[1:] + ('' if e.endswith('.') else '.')
def cancel_job(self):
if self.job and self.job.result is None:
self.job.cancel()
def retry_job(self):
j = self.job
if isinstance(j, jobs.InstallJob):
self.run_job(jobs.InstallJob(self.ctx, j.dump, j.game_dir), self.job_return, self.job_back)
elif isinstance(j, jobs.ContinueUpdateJob):
self.run_job(jobs.ContinueUpdateJob(self.ctx, j.game_dir, j.old, j.dump), self.job_return)
elif isinstance(j, jobs.RebuildJob):
self.run_job(jobs.RebuildJob(self.ctx, j.game_dir, j.dump), self.job_return)
elif isinstance(j, jobs.UpdateDownloadJob):
self.run_job(jobs.UpdateDownloadJob(self.ctx, j.release), self.job_return)
def copy_report(self):
j = self.job
report = [f'NX2navi {self.layout.version} ({"checkout" if self.layout.dev else "release"}), {sys.platform}',
f'Job: {j.kind}, step: {j.current().label}', f'Error: {j.error}', '', 'Last lines:']
report += [ln for ln in list(j.lines)[-60:]]
self.root.clipboard_clear()
self.root.clipboard_append('\n'.join(report))
self.message = 'Report copied.'
self.render()
def leave_job(self):
j = self.job
if j.result == 'ok':
self.show(self.job_return)
elif j.result == 'cancelled':
self.show(self.job_back)
else:
self.show(self.job_back)
def job_finished(self):
j = self.job
winsys.keep_awake(False)
self.ctx.log(f'job {j.kind}: {j.result} {j.error}')
if j.result == 'ok':
if j.after == 'restart':
if winsys.restart(self.layout, ['--continue-update', versions.display(self.layout.version)]):
self.root.after(200, self.root.destroy)
return
self.message = 'The update is ready: start NX2navi again to finish it.'
elif j.after == 'staged':
self.message = 'The update was downloaded; a checkout keeps running from the repository.'
if not self.settings.get('keep_cache') and j.kind in ('install', 'update', 'rebuild'):
self.drop_build_cache()
if j.kind == 'install':
self.message = 'The game is built. Select Play.'
elif j.kind in ('update', 'rebuild'):
self.message = 'The game is rebuilt and up to date.' if j.kind == 'rebuild' else \
f'NX2navi is now {versions.display(self.layout.version)}.'
self.release = None
self.reload_fonts_if_new()
self.show(self.job_return)
if j.kind == 'install' and self.settings.get('auto_check'):
self.check_now()
elif j.result == 'cancelled':
self.show(self.job_back)
else:
self.show('job')
def drop_build_cache(self):
"""Keep Build Cache off: delete the lifted C and the objects (about 5 GB) but keep state.json, which an
update needs to rebuild without the dump; the next build then lifts and compiles everything again."""
gd = self.settings.get('game_dir')
if not gd:
return
cache = Path(gd) / 'build-cache'
for name in ('lift', 'obj', 'gen'):
shutil.rmtree(cache / name, ignore_errors=True)
for name in ('link.rsp', 'link.key'):
(cache / name).unlink(missing_ok=True)
def reload_fonts_if_new(self):
if not self.game_fonts and gamefiles.cached_fonts(self.layout):
self.reload_fonts()
# ---------------------------------------------------------------- timers
def tick(self):
try:
if self.check and self.check.result is not None and not getattr(self.check, 'seen', False):
self.check.seen = True
self.check_done()
if self.screen == 'job' and self.job:
if self.job.rev != self.job_rev:
self.job_rev = self.job.rev
if self.job.result is not None:
self.job_finished()
else:
self.rows = self.rows_job()
self.focus = self.job_focus()
self.render()
elif int(time.time()) != self.last_second and self.job.result is None:
self.render()
self.last_second = int(time.time())
except Exception as e: # a drawing error must not stop the timer
self.ctx.log(f'tick: {e!r}')
self.root.after(150, self.tick)
def animate(self):
if self.cursor_frames:
self.cursor_k = (self.cursor_k + 1) % len(self.cursor_frames)
self.canvas.itemconfigure('cursor', image=self.cursor_frames[self.cursor_k])
self.root.after(250, self.animate)
# ---------------------------------------------------------------- drawing
def render(self):
c = self.canvas
c.delete('dyn')
if self.screen == 'job' and self.job:
if self.job.result is None or self.rows == []:
self.rows = self.rows_job()
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
self.draw_status()
self.draw_cursor()
self.draw_desc()
def draw_status(self):
if self.screen != 'job' or not self.job:
return
j = self.job
steps = [s for s in j.steps]
cur = j.current()
n = steps.index(cur) + 1 if cur in steps else len(steps)
left = f'Stopped at step {n} of {len(steps)}' if j.result == 'failed' else f'Step {n} of {len(steps)}'
if self.message:
left = self.message
y = self.X(H - 24)
self.canvas.create_text(self.X(PANEL[0] + 30), y, text=left, anchor='sw', font=self.f['status'],
fill=P['status'], tags='dyn')
self.canvas.create_text(self.X(PANEL[2] - 30), y, text=f'Elapsed {jobs.mmss(j.elapsed())}', anchor='se',
font=self.f['status'], fill=P['status'], tags='dyn')
def text(self, x, y, text, font, fill, anchor='w', shadow=True, width=0):
c = self.canvas
if shadow:
c.create_text(x + 1, y + 1, text=text, font=font, fill='#2a1e10', anchor=anchor, tags='dyn',
width=width)
return c.create_text(x, y, text=text, font=font, fill=fill, anchor=anchor, tags='dyn', width=width)
def draw_row(self, r, x0, y0, x1, y1):
c, s, X = self.canvas, self.s, self.X
r.rect = (x0, y0, x1, y1)
if r.kind == 'divider':
img = self.sprite(('filigree', x1 - x0), lambda: art.filigree(x1 - x0, s)[0])
c.create_image(x0, (y0 + y1) // 2, anchor='w', image=img, tags='dyn')
return
if r.kind in ('action', 'pair'):
parts = [(r.label, r.on)] if r.kind == 'action' else r.actions
gap = X(GAP)
w = (x1 - x0 - gap * (len(parts) - 1)) // len(parts)
r.parts = []
for k, (label, cb) in enumerate(parts):
px0 = x0 + k * (w + gap)
px1 = px0 + w if k < len(parts) - 1 else x1
img = self.sprite(('label', px1 - px0, y1 - y0), lambda a=px1 - px0, b=y1 - y0: art.label_cell(a, b, s))
ring = max(1, int(round(s)))
c.create_image(px0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
font = self.fit_font(self.f['action_big'] if r.h >= 50 else self.f['action'], label,
px1 - px0 - X(16))
self.text((px0 + px1) // 2, (y0 + y1) // 2, label, font, P['text_bright'], anchor='center')
r.parts.append(((px0, y0, px1, y1), label, cb))
r.value_rect = None
return
lw = X(LABEL_W)
lx1 = x0 + lw
img = self.sprite(('label', lw, y1 - y0), lambda: art.label_cell(lw, y1 - y0, s))
ring = max(1, int(round(s)))
c.create_image(x0 - ring, y0 - ring, anchor='nw', image=img, tags='dyn')
tx = x0 + X(16)
if r.icon:
size = X(32)
ic = self.sprite(('icon', r.icon, size), lambda: art.framed_icon(size, s, r.icon))
c.create_image(tx, (y0 + y1) // 2, anchor='w', image=ic, tags='dyn')
tx += size + X(14)
font = (self.f['big'] if r.icon else self.f['big_small']) if r.big else self.f['label']
font = self.fit_font(font, r.label, lx1 - tx - X(10) - (X(32) if r.badge else 0))
tid = self.text(tx, (y0 + y1) // 2, r.label, font, P['text_bright'] if r.big else P['text'])
if r.badge:
bx = self.canvas.bbox(tid)[2] + X(10)
size = X(22)
b = self.sprite(('badge', size), lambda: art.badge(size, s))
c.create_image(bx, (y0 + y1) // 2, anchor='w', image=b, tags='dyn')
c.create_text(bx + (size + 4) // 2, (y0 + y1) // 2, text='!', font=self.f['badge'], fill='#3a2208',
tags='dyn')
vx0 = lx1
r.value_rect = (vx0, y0, x1, y1)
c.create_rectangle(vx0, y0, x1 - 1, y1 - 1, fill=P['value'], outline=P['value_border'], width=1, tags='dyn')
pad = X(3) if not r.big else X(4)
ix0, iy0, ix1, iy1 = vx0 + pad, y0 + pad, x1 - pad, y1 - pad
cy = (y0 + y1) // 2
cx = (vx0 + x1) // 2
vfont = self.f['label'] if r.kind in ('toggle', 'enum', 'progress') or r.style == 'orange_arrow' \
else self.f['value']
if r.kind == 'toggle':
on = bool(r.get())
mid = (ix0 + ix1) // 2
for k, (label, sel) in enumerate((('On', on), ('Off', not on))):
sx0, sx1 = (ix0, mid) if k == 0 else (mid, ix1)
if sel:
self.orange(sx0, iy0, sx1, iy1)
self.canvas.create_text((sx0 + sx1) // 2, cy, text=label, font=vfont,
fill=P['on_text'] if sel else P['off'], tags='dyn')
elif r.kind == 'enum':
self.orange(ix0, iy0, ix1, iy1)
cw, ch = X(9), X(12)
for left in (True, False):
img = self.sprite(('chev', cw, ch, left, 'on'), lambda lf=left: art.chevron(cw, ch, P['on_text'], lf))
c.create_image(ix0 + X(9) if left else ix1 - X(9), cy, anchor='w' if left else 'e', image=img,
tags='dyn')
self.canvas.create_text(cx, cy, text=r.get(), font=vfont, fill=P['on_text'], tags='dyn')
elif r.kind == 'progress':
fw = int((ix1 - ix0) * max(0.0, min(1.0, r.fraction)))
if fw > 2:
self.orange(ix0, iy0, ix0 + fw, iy1)
self.canvas.create_text(cx + 1, cy + 1, text=r.value, font=vfont, fill='#1a120a', tags='dyn')
self.canvas.create_text(cx, cy, text=r.value, font=vfont, fill=P['on_text'], tags='dyn')
elif r.style == 'orange_arrow':
self.orange(ix0, iy0, ix1, iy1)
cw, ch = X(9), X(12)
label_w = vfont.measure(r.value)
total = label_w + X(10) + cw
sx = cx - total // 2
self.canvas.create_text(sx, cy, text=r.value, font=vfont, fill=P['on_text'], anchor='w', tags='dyn')
img = self.sprite(('chev', cw, ch, False, 'on'), lambda: art.chevron(cw, ch, P['on_text']))
c.create_image(sx + label_w + X(10), cy, anchor='w', image=img, tags='dyn')
elif r.style == 'arrow':
cw, ch = X(9), X(12)
img = self.sprite(('chev', cw, ch, False, 'dim'), lambda: art.chevron(cw, ch, '#d9ccae'))
c.create_image(x1 - X(16), cy, anchor='e', image=img, tags='dyn')
else:
color = {'normal': P['value_text'], 'dim': P['dim'], 'salmon': P['salmon']}.get(r.style, P['value_text'])
avail = (x1 - vx0) - X(20) - (X(21) if r.check else 0)
vfont = self.fit_font(vfont, r.value, avail, 0.8)
label = fit(vfont, r.value, avail)
tw = vfont.measure(label)
sx = cx - (tw + (X(21) if r.check else 0)) // 2
if r.check:
size = X(14)
ck = self.sprite(('check', size), lambda: art.check_mark(size, s))
c.create_image(sx, cy, anchor='w', image=ck, tags='dyn')
sx += X(21)
self.canvas.create_text(sx, cy, text=label, font=vfont, fill=color, anchor='w', tags='dyn')
def orange(self, x0, y0, x1, y1, tags=('dyn',)):
c = self.canvas
c.create_rectangle(x0, y0, x1 - 1, y1 - 1, fill=P['orange'], outline=P['orange_dark'], width=1, tags=tags)
if x1 - x0 > 4:
c.create_rectangle(x0 + 1, y0 + 1, x1 - 2, y1 - 2, outline=P['orange_light'], width=1, tags=tags)
def draw_cursor(self):
r = self.focused()
self.cursor_frames = []
if not r or not r.rect:
return
if r.kind == 'pair' and r.parts:
box = r.parts[getattr(r, 'pick', 0)][0]
elif (r.kind in ('toggle', 'enum', 'progress') or r.focus_value) and r.value_rect:
box = r.value_rect
else:
box = r.rect
x0, y0, x1, y1 = box
frames, mg = self.glow(x1 - x0, y1 - y0)
self.cursor_frames = frames
self.cursor_k %= len(frames)
self.canvas.create_image(x0 - mg, y0 - mg, anchor='nw', image=frames[self.cursor_k], tags=('dyn', 'cursor'))
def glow(self, w, h):
key = ('glow', w, h)
if key not in self.sprites:
frames, mg = art.glow_frames(w, h, self.s)
self.sprites[key] = ([art.photo(self.root, f) for f in frames], mg)
return self.sprites[key]
def draw_desc(self):
r = self.focused()
d = r.desc if r else None
if callable(d):
d = d()
if not d:
if self.message and self.screen != 'job':
d = (self.message,)
else:
return
if isinstance(d, tuple):
d = {'text': [t for t in d if t]}
elif self.message and self.screen in ('first_run', 'settings', 'uninstall'):
d = dict(d)
d['text'] = [self.message] + list(d.get('text', []))
c, X = self.canvas, self.X
dx, dw = DESC
x0 = X(dx)
w = X(dw)
padx, pady = X(26), X(22)
inner = w - 2 * padx
items = []
y = 0
def add(kind, *args):
items.append((kind, args))
if d.get('title'):
add('text', d['title'], self.f['heading'], P['text_bright'], X(14))
for t in d.get('text', []):
if t:
add('text', t, self.f['desc'], P['desc_text'], X(12))
if d.get('progress') is not None:
add('progress', d['progress'], d.get('progress_label', ('', '')))
if d.get('divider'):
add('filigree')
if d.get('subtitle'):
add('text', d['subtitle'], self.f['sub'], P['gold'], X(8))
for b in d.get('bullets', []):
add('bullet', b)
if d.get('log'):
add('log', d['log'])
if d.get('note'):
add('text', d['note'], self.f['small'], P['muted'], X(4))
# measure: draw at a provisional top, then move everything to the final top
top0 = X(112)
y = top0 + pady
made = []
for kind, args in items:
if kind == 'text':
text, font, fill, after = args
tid = c.create_text(x0 + padx, y, text=text, font=font, fill=fill, anchor='nw', width=inner,
tags=('dyn', 'desc'))
bb = c.bbox(tid)
y = bb[3] + after
made.append(tid)
elif kind == 'progress':
frac, (left, right) = args
c.create_text(x0 + padx, y, text=left, font=self.f['small'], fill=P['muted'], anchor='nw',
tags=('dyn', 'desc'))
c.create_text(x0 + w - padx, y, text=right, font=self.f['small'], fill=P['muted'], anchor='ne',
tags=('dyn', 'desc'))
y += self.f['small'].metrics('linespace') + X(6)
h = X(22)
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
outline=P['value_border'], tags=('dyn', 'desc'))
fw = int((inner - X(6)) * frac)
if fw > 2:
self.orange(x0 + padx + X(3), y + X(3), x0 + padx + X(3) + fw, y + h - X(3), ('dyn', 'desc'))
y += h + X(14)
elif kind == 'filigree':
img = self.sprite(('filigree', inner), lambda: art.filigree(inner, self.s)[0])
c.create_image(x0 + padx, y + X(9), anchor='w', image=img, tags=('dyn', 'desc'))
y += X(22)
elif kind == 'bullet':
(text,) = args
c.create_rectangle(x0 + padx + X(1), y + X(8), x0 + padx + X(6), y + X(13), fill='#c99a45',
outline='', tags=('dyn', 'desc'))
tid = c.create_text(x0 + padx + X(16), y, text=text, font=self.f['value'], fill=P['desc_text'],
anchor='nw', width=inner - X(16), tags=('dyn', 'desc'))
y = c.bbox(tid)[3] + X(8)
elif kind == 'log':
(lines,) = args
lh = self.f['mono'].metrics('linespace')
shown = [fit(self.f['mono'], ln, inner - X(24)) for ln in lines[-6:]]
h = lh * max(len(shown), 1) + X(20)
c.create_rectangle(x0 + padx, y, x0 + w - padx - 1, y + h - 1, fill=P['value'],
outline=P['value_border'], tags=('dyn', 'desc'))
ty = y + X(10)
for ln in shown:
color = P['salmon'] if ln.lower().startswith(('error', 'stopped')) else P['log_text']
c.create_text(x0 + padx + X(12), ty, text=ln, font=self.f['mono'], fill=color, anchor='nw',
tags=('dyn', 'desc'))
ty += lh
y += h + X(14)
height = y - top0 + pady - X(4)
top = X(236) if height < X(200) else X(112)
top = min(top, X(H - 30) - height) if top + height > X(H - 30) else top
dy = top - top0
if dy:
c.move('desc', 0, dy)
hb = max(X(60), height)
key = ('desc', w, hb // 4)
if key not in self.sprites:
img, pad = art.desc_box(w, hb, self.s)
self.sprites[key] = (art.photo(self.root, img), pad)
pic, pad = self.sprites[key]
bid = c.create_image(x0 - pad, top - pad, anchor='nw', image=pic, tags='dyn')
c.tag_lower(bid, 'desc')
def parse_args(argv):
ap = argparse.ArgumentParser(prog='NX2navi')
ap.add_argument('--from', dest='source', help='a folder to look in for the dump first (the setup\'s folder)')
ap.add_argument('--continue-update', metavar='OLD', help='finish an update after the restart (from OLD)')
ap.add_argument('--rolled-back', metavar='VERSION', help='the stub went back from VERSION')
ap.add_argument('--uninstall', action='store_true')
ap.add_argument('--root', help='the launcher folder (tests)')
ap.add_argument('--selftest', action='store_true', help='draw every screen and close')
ap.add_argument('--shots', help='with --selftest: save a PNG of each screen into this folder')
a, _unknown = ap.parse_known_args(argv)
return a
def single_instance(layout, wait):
"""An exclusive lock on data/launcher.lock for this process's life, or None when another launcher holds it after
wait seconds (the one an update restart replaces may still be closing)."""
path = layout.data / 'launcher.lock'
deadline = time.time() + wait
while True:
try:
f = open(path, 'a+')
try:
if os.name == 'nt':
import msvcrt
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
return f
except OSError:
f.close()
raise
except OSError:
if time.time() >= deadline:
return None
time.sleep(0.25)
def main(argv=None):
a = parse_args(sys.argv[1:] if argv is None else argv)
winsys.dpi_aware()
winsys.app_id()
from ..paths import Layout
ctx = Context(Layout(root=a.root) if a.root else None)
lock = None
if not a.selftest:
lock = single_instance(ctx.layout, 20 if (a.continue_update or a.rolled_back) else 2)
if lock is None:
ctx.log('another launcher is running')
return 0
root = tk.Tk(className='NX2navi')
app = App(root, ctx, a)
if a.selftest:
from .selftest import run
root.after(300, lambda: run(app, a.shots))
root.mainloop()
return 0