mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
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>
756 lines
35 KiB
Python
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())
|