Files
Valiantsin DzerakhandClaude Opus 5.5 c2c68aa2cc Prefilled base and update paths stay only while the chosen install recorded the same files
Another install, say 1.2.1 while the recorded update is 1.4.3, would extract again from them. Each dest change compares the prefilled file's name and size (one stat) with the install's totk-install.json inputs and clears the field on a mismatch. Fields the user picked or typed are never cleared, and a folder without state keeps the prefill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:27:03 +03:00

756 lines
35 KiB
Python

"""The installer window: python -m installer.gui
A front end over the CLI (docs/installer.md): it collects the game files, the keys file path, the optional firmware
folder, the game language and the install folder, runs `python -m installer ...` as a subprocess and shows its JSON
events. Decisions live in guilogic.py; this file only draws. The window text is in
installer/text/<en|ru|pt-BR|zh-Hans>.json under "ui.*" and "launcher.*" ids. On Windows the window follows the
display's DPI (installer/dpi.py): fonts and paddings grow with the scale, so Windows does not stretch it.
--selftest [--shots DIR] build every screen in every language and close by itself (PNG per screen with --shots)
--lang en|ru|pt-BR|zh-Hans window language (default: the Windows display language)
--rebuild DIR rebuild the install folder DIR for its mods (totk.exe starts this, build-cache/rebuild.txt):
the run starts at once with that folder, and the game starts again when it succeeds
"""
import argparse
import json
import os
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
from . import dpi
# Windows fixes the DPI awareness of a process when its first window is created, and Tk reads the display's DPI when it
# starts: both happen inside the first tkinter.Tk(), so this call comes before tkinter is imported at all.
dpi.enable()
import tkinter as tk # noqa: E402 (after dpi.enable, see above)
from tkinter import filedialog, messagebox, ttk # noqa: E402
try:
from PIL import ImageGrab # only the screenshots of --selftest --shots need it; the release has no PIL
except ImportError:
ImageGrab = None
from . import ini
from . import layout as layouts
from . import recent
from .guilogic import (DONE, FAILED, PENDING, RUNNING, SKIPPED, UI_LANGUAGE_LABELS, UI_LANGUAGES, Catalog, Form, Model,
Process, amount, default_game_language, describe_event, parse_line, windows_ui_language)
POLL_MS = 100
GLYPH = {PENDING: '\u25cb', RUNNING: '\u25b6', DONE: '\u2714', SKIPPED: '\u2013', FAILED: '\u2716'}
COLOR = {PENDING: '#808080', RUNNING: '#1a5fb4', DONE: '#26a269', SKIPPED: '#808080', FAILED: '#c01c28'}
# The window's pixels at 100%: Tk scales the fonts by itself (tk scaling), these through installer/dpi.py.
PAD = dpi.px(14) # padding of the window and its frames
WRAP = dpi.px(620) # wrap width of the long labels
RECENT_WAIT = 1.5 # seconds the window waits for the recorded paths
def load_recent(wait=RECENT_WAIT):
"""recent.load() in a daemon thread, waited for at most `wait` seconds: a recorded path on a disconnected network
share can block its stat call for a long time. Nothing is prefilled when it has not returned by then."""
out = {}
thread = threading.Thread(target=lambda: out.update(recent.load()), daemon=True)
thread.start()
thread.join(wait)
return {} if thread.is_alive() else out
def fix_tcl_paths():
"""The embeddable Python keeps Tcl/Tk next to python.exe (python/tcl); point Tcl at it when it is not found."""
tcl = Path(sys.executable).resolve().parent / 'tcl'
if tcl.is_dir() and 'TCL_LIBRARY' not in os.environ:
for lib, var in (('tcl9.0', 'TCL_LIBRARY'), ('tk9.0', 'TK_LIBRARY')):
if (tcl / lib).is_dir():
os.environ[var] = str(tcl / lib)
class App:
def __init__(self, root, ui_lang, layout, run_args=None, rebuild=None):
self.root, self.layout = root, layout
self.rebuild = rebuild # the install folder of --rebuild, or None
self.cat = Catalog(ui_lang)
self.model = Model()
self.process = None
self.closing = False
self.run_args = run_args or {} # extra CLI options for tests: temp, verify
self.log_entries = [] # events and raw lines of the run, rendered by render_log
self.bound = [] # (widget, option, id, params) retranslated on a language switch
self.vars = {n: tk.StringVar() for n in ('base', 'update', 'keys', 'firmware', 'dest')}
self.vars['lang'] = tk.StringVar()
self.recorded = {} # base/update paths prefilled from the recorded ones, by field
self.touched = set() # fields the user picked or typed
self.programmatic = False
for name in ('base', 'update'):
self.vars[name].trace_add('write', lambda *_, n=name: self.touched.add(n) if not self.programmatic else None)
self.lang_code = default_game_language(ui_lang)
self.screen = None
self.fitted = None # the (width, height) fit last gave the window
root.title(self.cat('ui.title'))
icon = layout.runtime / 'totk.ico'
png = layout.runtime / 'totk.png' # Tk loads no .ico off Windows; the Linux release converts it
if os.name == 'nt' and icon.is_file():
root.iconbitmap(default=str(icon))
elif png.is_file():
self.icon = tk.PhotoImage(file=str(png))
root.iconphoto(True, self.icon)
root.protocol('WM_DELETE_WINDOW', self.on_close)
self.ui_font = 'Segoe UI' if os.name == 'nt' else 'DejaVu Sans'
self.mono_font = 'Consolas' if os.name == 'nt' else 'DejaVu Sans Mono'
style = ttk.Style(root)
style.configure('Title.TLabel', font=(self.ui_font, 15, 'bold'))
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=dpi.px(8))
style.configure('Err.TLabel', foreground='#c01c28')
style.configure('Version.TLabel', foreground='#808080')
top = ttk.Frame(root, padding=(PAD, PAD, PAD, 0))
top.pack(fill='x')
self.title = self.label(top, 'ui.title', style='Title.TLabel')
self.title.pack(side='left')
version = ttk.Label(top, style='Version.TLabel')
self.bind_text(version, 'text', 'ui.version', version=layout.release)
version.pack(side='left', padx=(dpi.px(10), 0), pady=(dpi.px(6), 0))
sw = ttk.Frame(top)
sw.pack(side='right')
self.ui_var = tk.StringVar(value=self.cat.lang) # the window language code; the box shows its name
labels = [UI_LANGUAGE_LABELS[c] for c in UI_LANGUAGES]
self.ui_box = ttk.Combobox(sw, values=labels, state='readonly', width=max(map(len, labels)) + 1)
self.ui_box.set(UI_LANGUAGE_LABELS[self.cat.lang])
self.ui_box.bind('<<ComboboxSelected>>', self.pick_language)
self.ui_box.pack(side='left')
self.body = ttk.Frame(root, padding=PAD)
self.body.pack(fill='both', expand=True)
self.frames = {'form': self.build_form(), 'run': self.build_run(), 'finish': self.build_finish()}
self.vars['lang'].set(self.lang_label(self.lang_code))
self.vars['dest'].trace_add('write', lambda *_: self.refresh_form())
self.show('form')
if rebuild:
self.bind_text(self.title, 'text', 'ui.rebuild_title')
self.vars['dest'].set(str(rebuild))
root.after(POLL_MS, self.start)
else:
# recorded sources fill only empty fields; dest goes last, its trace runs refresh_form (update hint and
# recorded language). In update mode filled files are harmless: Extract.current hashes one only when its
# size or mtime differ from the recorded ones.
last = load_recent()
self.recorded = {n: last[n] for n in ('base', 'update') if n in last and not self.vars[n].get().strip()}
for name in ('base', 'update', 'keys', 'firmware', 'dest'):
if name in last and not self.vars[name].get().strip():
self.set_prefill(name, last[name])
# ---- text -----------------------------------------------------------------------------------------------------
def bind_text(self, widget, option, id, **params):
self.bound.append((widget, option, id, params))
widget.configure(**{option: self.cat(id, **params)})
def label(self, parent, id, **kw):
w = ttk.Label(parent, **kw)
self.bind_text(w, 'text', id)
return w
def button(self, parent, id, command, **kw):
w = ttk.Button(parent, command=command, **kw)
self.bind_text(w, 'text', id)
return w
def retext(self):
self.root.title(self.cat('ui.title'))
for widget, option, id, params in self.bound:
widget.configure(**{option: self.cat(id, **params)})
self.fill_languages()
self.refresh_form()
self.refresh_run()
self.refresh_finish()
self.render_log()
def pick_language(self, _event=None):
self.ui_var.set(UI_LANGUAGES[self.ui_box.current()])
self.switch_language()
def switch_language(self):
self.cat.set_language(self.ui_var.get())
self.ui_box.set(UI_LANGUAGE_LABELS[self.ui_var.get()])
code = self.selected_lang()
self.retext()
self.vars['lang'].set(self.lang_label(code))
self.fit()
def lang_label(self, code):
return self.cat('lang.' + code)
def fill_languages(self):
code = self.selected_lang()
self.lang_box.configure(values=[self.lang_label(c) for c in ini.LANGUAGES])
self.vars['lang'].set(self.lang_label(code))
def selected_lang(self):
"""The game language code behind the combobox text (kept when it is not one of the labels)."""
shown = self.vars['lang'].get()
for code in ini.LANGUAGES:
for lang in UI_LANGUAGES:
if shown == Catalog(lang)('lang.' + code):
return code
return self.lang_code
# ---- screens --------------------------------------------------------------------------------------------------
def show(self, name):
for f in self.frames.values():
f.pack_forget()
self.frames[name].pack(fill='both', expand=True)
self.screen = name
self.fit()
def fit(self):
"""Size the window to its largest screen (an unpacked frame still reports the size its children ask for), so
a screen or language switch neither cuts text nor moves the buttons. The window only grows: opening the log
makes it taller, and a size the user dragged it to stays. Every text change that can add lines calls it again,
or pack takes the missing height from the button bars."""
self.root.update_idletasks()
sizes = [(f.winfo_reqwidth(), f.winfo_reqheight()) for f in self.frames.values()]
chrome = self.root.winfo_reqheight() - self.body.winfo_reqheight() # the title row above the body
w = max(self.root.winfo_reqwidth(), max(s[0] for s in sizes) + 2 * PAD)
h = chrome + max(s[1] for s in sizes) + 2 * PAD
if (w, h) == self.fitted: # refresh_run calls it on every poll: leave the window alone while nothing grew
return
self.fitted = (w, h)
self.root.minsize(w, h)
self.root.geometry(f'{max(w, self.root.winfo_width())}x{max(h, self.root.winfo_height())}')
def notice(self, parent):
w = ttk.Label(parent, style='Notice.TLabel', wraplength=WRAP, justify='left')
self.bind_text(w, 'text', 'ui.notice')
return w
def build_form(self):
f = ttk.Frame(self.body)
gap = dpi.px(4) # between the form's rows
bar = ttk.Frame(f) # packed first so pack shrinks the text above it, never the buttons
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
self.start_button = self.button(bar, 'ui.start', self.start)
self.start_button.pack(side='right')
self.form_intro = self.label(f, 'ui.intro', wraplength=WRAP, justify='left')
self.form_intro.pack(anchor='w', pady=(0, dpi.px(8)))
self.notice(f).pack(fill='x', pady=(0, dpi.px(10)))
grid = ttk.Frame(f)
grid.pack(fill='x')
grid.columnconfigure(1, weight=1)
rows = (('base', 'ui.base', 'ui.pick_game'), ('update', 'ui.update', 'ui.pick_update'),
('keys', 'ui.keys', 'ui.pick_keys'))
self.form_rows = {}
for i, (name, label_id, pick_id) in enumerate(rows):
lab = self.label(grid, label_id)
lab.grid(row=i, column=0, sticky='w', pady=gap, padx=(0, dpi.px(10)))
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=gap)
b = self.button(grid, 'ui.browse', lambda n=name, p=pick_id: self.pick_file(n, p))
b.grid(row=i, column=2, padx=(dpi.px(8), 0))
self.form_rows[name] = lab
i = len(rows)
self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=gap)
ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=gap)
self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(dpi.px(8), 0))
i += 1
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=gap)
self.lang_box = ttk.Combobox(grid, textvariable=self.vars['lang'], state='readonly', width=30)
self.lang_box.grid(row=i, column=1, sticky='w', pady=gap)
self.lang_touched = False
self.lang_box.bind('<<ComboboxSelected>>', lambda _e: setattr(self, 'lang_touched', True))
self.fill_languages()
self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=gap)
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=gap)
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(dpi.px(8), 0))
self.label(f, 'ui.compressed_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
self.label(f, 'ui.firmware_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
# On by default and named with its source and licence: the player sees what is downloaded and can untick it
self.dlss_var = tk.BooleanVar(value=True)
dlss = ttk.Checkbutton(f, variable=self.dlss_var)
self.bind_text(dlss, 'text', 'ui.dlss')
dlss.pack(anchor='w', pady=(dpi.px(8), 0))
self.label(f, 'ui.dlss_help', wraplength=WRAP, justify='left').pack(anchor='w', padx=(dpi.px(22), 0))
self.update_hint = ttk.Label(f, wraplength=WRAP, justify='left')
self.update_hint.pack(anchor='w', pady=(dpi.px(8), 0))
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=WRAP, justify='left')
self.form_error.pack(anchor='w', pady=(dpi.px(8), 0))
self.label(f, 'ui.keys_private', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
return f
def build_run(self):
f = ttk.Frame(self.body)
bar = ttk.Frame(f)
bar.pack(side='bottom', fill='x', pady=(dpi.px(8), 0))
self.cancel_button = self.button(bar, 'ui.cancel', self.cancel)
self.cancel_button.pack(side='right')
self.log_toggle = self.button(bar, 'ui.show_log', self.toggle_log)
self.log_toggle.pack(side='left')
self.run_header = ttk.Label(f, font=(self.ui_font, 11, 'bold'))
self.run_header.pack(anchor='w')
self.step_rows = {}
self.steps_box = ttk.Frame(f)
self.steps_box.pack(fill='x', pady=dpi.px(8))
self.phase_label = ttk.Label(f, wraplength=WRAP, justify='left')
self.phase_label.pack(anchor='w')
bar_holder = ttk.Frame(f)
bar_holder.pack(fill='x', pady=(dpi.px(4), dpi.px(6)))
self.bar = ttk.Progressbar(bar_holder, mode='determinate', maximum=1000)
self.bar.pack(fill='both', expand=True)
if os.name == 'nt' and ttk.Style(f).theme_use() == 'vista':
# The vista theme draws the bar 22 px high at every DPI and has no thickness option: the holder gives it the
# height it would have at 100%, scaled with the display. Every other theme draws its own height, and the
# holder follows it.
bar_holder.pack_propagate(False)
bar_holder.configure(height=dpi.px(22))
self.space_label = ttk.Label(f, wraplength=WRAP, justify='left')
self.space_label.pack(anchor='w')
self.warn_label = ttk.Label(f, wraplength=WRAP, justify='left', foreground='#9a6700')
self.warn_label.pack(anchor='w')
self.log_frame = ttk.Frame(f)
self.log = tk.Text(self.log_frame, height=10, wrap='word', state='disabled', font=(self.mono_font, 9))
scroll = ttk.Scrollbar(self.log_frame, command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
scroll.pack(side='right', fill='y')
self.log.pack(side='left', fill='both', expand=True)
self.log_open = False
return f
def build_finish(self):
f = ttk.Frame(self.body)
bar = ttk.Frame(f)
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
self.button(bar, 'ui.close', self.on_close).pack(side='right')
self.open_button = self.button(bar, 'ui.open_folder', self.open_folder)
self.open_button.pack(side='right', padx=(0, dpi.px(8)))
self.back_button = self.button(bar, 'ui.back', lambda: self.show('form'))
self.back_button.pack(side='left')
if self.rebuild: # a failed or cancelled rebuild: the game starts with the code it has
self.button(bar, 'ui.start_game', self.start_game).pack(side='right', padx=(0, dpi.px(8)))
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=WRAP, justify='left')
self.finish_title.pack(anchor='w')
self.finish_text = ttk.Label(f, wraplength=WRAP, justify='left')
self.finish_text.pack(anchor='w', pady=dpi.px(8))
self.finish_steps = ttk.Frame(f)
self.finish_steps.pack(fill='x', pady=(0, dpi.px(8)))
self.finish_notice = self.notice(f)
self.finish_notice.pack(fill='x', pady=(dpi.px(8), 0))
return f
# ---- form -----------------------------------------------------------------------------------------------------
def form(self):
return Form(base=self.vars['base'].get(), update=self.vars['update'].get(), keys=self.vars['keys'].get(),
firmware=self.vars['firmware'].get(), lang=self.selected_lang(), dest=self.vars['dest'].get(),
dlss=self.dlss_var.get())
def set_prefill(self, name, value):
self.programmatic = True
try:
self.vars[name].set(value)
finally:
self.programmatic = False
def match_prefill(self, state):
"""Prefilled base/update stay only while the chosen install recorded the same file (name and size, one stat);
another install would otherwise extract again from them. Fields the user touched are left alone."""
for name, value in self.recorded.items():
if name in self.touched:
continue
keep = True
if state is not None:
known = (state.get('inputs') or {}).get(name)
try:
keep = (isinstance(known, dict) and Path(value).name == known.get('name')
and Path(value).stat().st_size == known.get('size'))
except (OSError, ValueError):
keep = False
want = value if keep else ''
if self.vars[name].get() != want:
self.set_prefill(name, want)
def refresh_form(self):
form = self.form()
state = form.installed_state()
self.match_prefill(state)
if state is not None:
self.update_hint.configure(text=self.cat('ui.update_mode'))
recorded = ini.canonical_language(state.get('lang'))
if recorded in ini.LANGUAGES and not self.lang_touched:
self.vars['lang'].set(self.lang_label(recorded))
else:
self.update_hint.configure(text='')
self.fit()
def pick_file(self, name, pick_id):
# One filter for every game file: the setup tells an NSZ or XCZ from a plain file by its content (Check.unpack)
types = [(self.cat('ui.type_game'), '*.nsp *.xci *.nsz *.xcz'), (self.cat('ui.type_all'), '*.*')]
if name == 'keys':
types = [(self.cat('ui.type_keys'), '*.keys'), (self.cat('ui.type_all'), '*.*')]
path = filedialog.askopenfilename(title=self.cat(pick_id), filetypes=types)
if path:
self.vars[name].set(os.path.normpath(path))
def pick_firmware(self):
path = filedialog.askdirectory(title=self.cat('ui.pick_firmware'), mustexist=True)
if path:
self.vars['firmware'].set(os.path.normpath(path))
def pick_dest(self):
path = filedialog.askdirectory(title=self.cat('ui.pick_dest'), mustexist=False)
if path:
self.vars['dest'].set(os.path.normpath(path))
# ---- run ------------------------------------------------------------------------------------------------------
def start(self):
form = self.form()
problems = form.problems()
if problems:
self.form_error.configure(text=' '.join(self.cat(p) for p in problems))
self.fit()
return
self.form_error.configure(text='')
recent.save(form.dest, {name: getattr(form, name) for name in recent.SOURCES})
# in a repository the Python running this window: the tools' embeddable Python there has no ._pth entry for
# the repository, so `-m installer` would not find the package
python = Path(sys.executable) if self.layout.dev else self.layout.python
command = form.command(python, root=self.layout.root if self.layout.dev else None,
temp=self.run_args.get('temp'), verify_hashes=self.run_args.get('verify', False))
self.begin(command, self.layout.root)
def begin(self, command, cwd):
self.model = Model()
self.model.dest = self.vars['dest'].get().strip()
self.clear_log()
self.build_step_rows(self.model.steps)
self.cancel_button.configure(state='normal')
self.bound = [b for b in self.bound if b[0] is not self.cancel_button]
self.bind_text(self.cancel_button, 'text', 'ui.cancel')
self.show('run')
self.refresh_run()
self.process = Process(command, cwd=cwd, helper_python=self.layout.python)
self.root.after(POLL_MS, self.poll)
def build_step_rows(self, steps):
for w in self.steps_box.winfo_children():
w.destroy()
self.step_rows = {}
for s in steps:
row = ttk.Frame(self.steps_box)
row.pack(fill='x', pady=dpi.px(1))
glyph = ttk.Label(row, width=3)
glyph.pack(side='left')
name = ttk.Label(row)
name.pack(side='left')
self.step_rows[s] = (glyph, name)
def poll(self):
if self.process is None:
return
try:
while True:
line = self.process.lines.get_nowait()
if line is not None:
self.feed(line)
except Exception:
pass
code = self.process.poll()
self.refresh_run()
if code is None:
self.root.after(POLL_MS, self.poll)
return
self.model.finish(code)
self.process = None
if self.rebuild and self.model.result == 'ok' and not self.closing:
self.start_game()
return
self.refresh_run()
self.refresh_finish()
self.show('finish')
if self.closing:
self.root.destroy()
def feed(self, line):
"""One stdout line of the CLI: an event updates the model and the log, anything else goes to the log as is."""
event = parse_line(line)
if event is None:
if line.strip():
self.log_entries.append(line.rstrip())
self.append_log(line.rstrip())
return
before = list(self.model.steps)
self.model.apply(event)
if self.model.steps != before:
self.build_step_rows(self.model.steps)
self.log_entries.append(event)
self.append_log(describe_event(self.cat, event))
def refresh_run(self):
m = self.model
for s, (glyph, name) in self.step_rows.items():
state = m.states.get(s, PENDING)
glyph.configure(text=GLYPH[state], foreground=COLOR[state])
name.configure(text=self.cat('step.' + s) + (f' ({self.cat("ui.state_" + state)})'
if state in (SKIPPED, FAILED) else ''),
font=(self.ui_font, 10, 'bold') if state == RUNNING else (self.ui_font, 10))
mode = m.mode or 'install'
self.run_header.configure(text=self.cat('ui.running_' + mode if mode in ('install', 'resume', 'update')
else 'ui.running_install'))
if m.current and m.phase:
text = self.cat(f'phase.{m.current}.{m.phase}')
if m.total:
text += ' ' + amount(self.cat, m.done, m.total, m.unit)
elif m.current:
text = self.cat('step.' + m.current)
else:
text = ''
self.phase_label.configure(text=text)
frac = m.fraction()
if frac is None:
if str(self.bar.cget('mode')) != 'indeterminate':
self.bar.configure(mode='indeterminate')
self.bar.start(15)
else:
if str(self.bar.cget('mode')) == 'indeterminate':
self.bar.stop()
self.bar.configure(mode='determinate')
self.bar.configure(value=frac * 1000)
self.space_label.configure(text=m.need_text(self.cat))
self.warn_label.configure(text='\n'.join(self.cat(i, **p) for i, p in m.warnings))
self.fit() # new step rows and warnings make the screen taller
def toggle_log(self):
self.log_open = not self.log_open
if self.log_open:
self.log_frame.pack(fill='both', expand=True, pady=(dpi.px(8), 0))
else:
self.log_frame.pack_forget()
self.bound = [b for b in self.bound if b[0] is not self.log_toggle]
self.bind_text(self.log_toggle, 'text', 'ui.hide_log' if self.log_open else 'ui.show_log')
self.fit()
def clear_log(self):
self.log_entries = []
self.render_log()
def render_log(self):
"""The whole log from the kept events in the current language (raw CLI lines stay as they are)."""
self.log.configure(state='normal')
self.log.delete('1.0', 'end')
for entry in self.log_entries:
text = entry if isinstance(entry, str) else describe_event(self.cat, entry)
if text:
self.log.insert('end', text + '\n')
self.log.see('end')
self.log.configure(state='disabled')
def append_log(self, text):
if text:
self.log.configure(state='normal')
self.log.insert('end', text + '\n')
self.log.see('end')
self.log.configure(state='disabled')
def cancel(self):
if self.process:
self.cancel_button.configure(state='disabled')
self.bound = [b for b in self.bound if b[0] is not self.cancel_button]
self.bind_text(self.cancel_button, 'text', 'ui.cancelling')
self.process.cancel()
def on_close(self):
if self.process:
if not messagebox.askyesno(self.cat('ui.title'), self.cat('ui.confirm_cancel')):
return
self.closing = True
self.cancel()
return
self.root.destroy()
# ---- finish ---------------------------------------------------------------------------------------------------
def refresh_finish(self):
m = self.model
result = m.result or 'ok'
self.finish_title.configure(text=self.cat('ui.result_' + result))
if result == 'ok':
text = self.cat('ui.done_text', dest=m.dest)
elif result == 'cancelled':
text = self.cat('ui.cancelled_text')
else:
err = m.error or ('ui.process_died', {'code': m.exit_code}, None)
text = self.cat(err[0], **err[1])
if m.log_path:
text += '\n\n' + self.cat('ui.log_file', path=m.log_path)
self.finish_text.configure(text=text)
for w in self.finish_steps.winfo_children():
w.destroy()
for s in m.steps:
state = m.states.get(s, PENDING)
ttk.Label(self.finish_steps, text=f'{GLYPH[state]} {self.cat("step." + s)}',
foreground=COLOR[state]).pack(anchor='w')
if result == 'ok':
self.open_button.state(['!disabled'])
else:
self.open_button.state(['disabled' if not Path(m.dest or '.').is_dir() else '!disabled'])
def start_game(self):
"""Starts the game of the install folder and closes the window."""
dest = Path(self.vars['dest'].get().strip())
exe = dest / ('totk.exe' if os.name == 'nt' else 'totk')
flags = subprocess.DETACHED_PROCESS if os.name == 'nt' else 0
if exe.is_file():
subprocess.Popen([str(exe)], cwd=str(dest), creationflags=flags, close_fds=True)
self.root.destroy()
def open_folder(self):
dest = self.model.dest
if dest and Path(dest).is_dir():
if os.name == 'nt':
os.startfile(dest)
else:
subprocess.Popen(['xdg-open', dest], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
# ---- self test ---------------------------------------------------------------------------------------------------
SAMPLE_START = {'type': 'start', 'release': 'selftest', 'mode': 'install', 'dest': 'Z:/selftest/TotK', 'log': 'D:\\log.txt',
'steps': ['check', 'fonts', 'extract', 'lift', 'compile', 'prepare', 'dlss', 'cleanup']}
SAMPLE_RUN = [
SAMPLE_START,
{'type': 'plan', 'run': ['check', 'fonts', 'extract'], 'skip': {}},
{'type': 'step', 'step': 'check', 'state': 'started'},
{'type': 'progress', 'step': 'check', 'phase': 'inputs'},
{'type': 'space', 'dest': 'Z:/selftest/TotK', 'need': 36_400_000_000, 'free': 210_000_000_000, 'temp': 'C:\\t',
'temp_need': 0, 'temp_free': 0, 'same_disk': True},
{'type': 'warning', 'id': 'warn.lang_needs_fonts', 'params': {'lang': 'ko'}},
{'type': 'step', 'step': 'check', 'state': 'done', 'seconds': 12.0},
{'type': 'step', 'step': 'extract', 'state': 'started'},
{'type': 'progress', 'step': 'extract', 'phase': 'copy', 'done': 6_000_000_000, 'total': 16_000_000_000,
'unit': 'bytes'},
]
SAMPLE_OK = [SAMPLE_START, {'type': 'plan', 'run': ['check', 'extract'], 'skip': {'fonts': 'current', 'lift': 'current',
'compile': 'current', 'prepare': 'current',
'cleanup': 'not_needed'}},
{'type': 'step', 'step': 'check', 'state': 'started'},
{'type': 'step', 'step': 'check', 'state': 'done', 'seconds': 1.0},
{'type': 'step', 'step': 'extract', 'state': 'started'},
{'type': 'step', 'step': 'extract', 'state': 'done', 'seconds': 1.0},
{'type': 'step', 'step': 'fonts', 'state': 'skipped', 'reason': 'current'},
{'type': 'step', 'step': 'lift', 'state': 'skipped', 'reason': 'current'},
{'type': 'step', 'step': 'compile', 'state': 'skipped', 'reason': 'current'},
{'type': 'step', 'step': 'prepare', 'state': 'skipped', 'reason': 'current'},
{'type': 'step', 'step': 'cleanup', 'state': 'done', 'seconds': 0.1},
{'type': 'end', 'result': 'ok', 'seconds': 3}]
def grab(root, path):
if ImageGrab is None:
raise SystemExit('--shots needs Pillow')
root.attributes('-topmost', True)
root.update()
x, y, w, h = root.winfo_rootx(), root.winfo_rooty(), root.winfo_width(), root.winfo_height()
ImageGrab.grab(bbox=(x, y, x + w, y + h), all_screens=True).save(path)
def selftest(layout, shots=None):
"""Every screen in every language; the window closes itself. Returns 0 when nothing raised."""
fix_tcl_paths()
os.environ[recent.ENV] = os.path.join(tempfile.mkdtemp(prefix='totk-selftest-'), 'setup.json')
root = tk.Tk()
root.geometry('+60+40')
app = App(root, 'en', layout)
steps, mixed = [], []
def shot(name):
root.update()
if shots:
Path(shots).mkdir(parents=True, exist_ok=True)
grab(root, Path(shots) / f'{name}.png')
def run_all():
for lang in UI_LANGUAGES:
app.ui_var.set(lang)
app.switch_language()
tag = lang
app.vars['dest'].set('Z:/selftest/TotK')
app.vars['base'].set('Z:/selftest/totk.xci')
app.vars['update'].set('Z:/selftest/totk-1.2.1.nsp')
app.vars['keys'].set('Z:/selftest/prod.keys')
app.show('form')
app.start() # missing files: shows the problems
steps.append(app.form_error.cget('text'))
shot(f'form-{tag}')
app.model = Model()
app.clear_log()
app.build_step_rows(app.model.steps)
app.show('run')
for e in SAMPLE_RUN:
app.feed(json.dumps(e))
app.log_open = False
app.toggle_log()
app.refresh_run()
shot(f'run-{tag}')
if lang == 'en': # the log follows the window language: no English line may remain after the switch
app.ui_var.set('ru')
app.switch_language()
text = app.log.get('1.0', 'end')
mixed.append('Checking' in text or 'Extracting' not in text and 'Распаковка' not in text)
app.ui_var.set('en')
app.switch_language()
app.model = Model()
for e in SAMPLE_OK:
app.model.apply(e)
app.model.dest = 'Z:/selftest/TotK'
app.model.finish(0)
app.refresh_finish()
app.show('finish')
shot(f'finish-ok-{tag}')
app.model = Model()
for e in SAMPLE_RUN[:3] + [{'type': 'error', 'id': 'check.disk_space', 'step': 'check',
'params': {'path': 'Z:/selftest/TotK', 'need_gb': 36.4, 'free_gb': 20.1}},
{'type': 'end', 'result': 'failed', 'seconds': 3}]:
app.model.apply(e)
app.model.finish(1)
app.refresh_finish()
shot(f'finish-failed-{tag}')
app.model = Model()
app.model.finish(3)
app.refresh_finish()
shot(f'finish-cancelled-{tag}')
app.toggle_log()
root.destroy()
root.after(200, run_all)
root.after(60000, root.destroy) # never leaves a window open
root.mainloop()
return 0 if len(steps) == len(UI_LANGUAGES) and all(steps) and mixed == [False] else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--selftest', action='store_true')
ap.add_argument('--shots', type=Path)
ap.add_argument('--lang', choices=UI_LANGUAGES)
ap.add_argument('--root', type=Path)
ap.add_argument('--rebuild', type=Path)
args = ap.parse_args(argv)
layout = layouts.find(args.root)
if args.selftest:
return selftest(layout, args.shots)
fix_tcl_paths()
root = tk.Tk()
App(root, args.lang or windows_ui_language(), layout, rebuild=args.rebuild)
root.mainloop()
return 0
if __name__ == '__main__':
sys.exit(main())