mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-12 02:11:03 +00:00
Choosing Simplified Chinese in the installer wrote lang = zh-CN to totk.ini and the game showed Japanese: ov_language() handed the code to nn::oe::GetDesiredLanguage unchanged, and TotK only accepts zh-Hans and zh-Hant for Chinese. Reported in #72 and #68. ov_language() now maps both old spellings, so existing totk.ini files keep working and cfg.c still accepts them. The F1 menu offers one row per language under the canonical codes. The installer lists only zh-Hans and zh-Hant, writes them, and reads zh-CN or zh-TW from the CLI or from a recorded totk-install.json as the canonical code, so a repair or update of an old install does not fail with check.lang_unknown and rewrites the ini. --probe-services prints the configured language and checks the four Chinese codes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
617 lines
27 KiB
Python
617 lines
27 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 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>.json under "ui.*" and "launcher.*" ids.
|
|
|
|
--selftest [--shots DIR] build every screen in both languages and close by itself (PNG per screen with --shots)
|
|
--lang en|ru window language (default: the Windows display language)
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import tkinter as tk
|
|
from pathlib import Path
|
|
from tkinter import filedialog, messagebox, ttk
|
|
|
|
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 .guilogic import (DONE, FAILED, PENDING, RUNNING, SKIPPED, 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'}
|
|
PAD = 14
|
|
|
|
|
|
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):
|
|
self.root, self.layout = root, layout
|
|
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', 'dest')}
|
|
self.vars['lang'] = tk.StringVar()
|
|
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'
|
|
if os.name == 'nt' and icon.is_file():
|
|
root.iconbitmap(default=str(icon))
|
|
root.protocol('WM_DELETE_WINDOW', self.on_close)
|
|
style = ttk.Style(root)
|
|
style.configure('Title.TLabel', font=('Segoe UI', 15, 'bold'))
|
|
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=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=(10, 0), pady=(6, 0))
|
|
sw = ttk.Frame(top)
|
|
sw.pack(side='right')
|
|
self.ui_var = tk.StringVar(value=self.cat.lang)
|
|
for code in UI_LANGUAGES:
|
|
ttk.Radiobutton(sw, text=code.upper(), value=code, variable=self.ui_var, style='Toolbutton',
|
|
command=self.switch_language).pack(side='left', padx=1)
|
|
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')
|
|
|
|
# ---- 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 switch_language(self):
|
|
self.cat.set_language(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=620, justify='left')
|
|
self.bind_text(w, 'text', 'ui.notice')
|
|
return w
|
|
|
|
def build_form(self):
|
|
f = ttk.Frame(self.body)
|
|
bar = ttk.Frame(f) # packed first so pack shrinks the text above it, never the buttons
|
|
bar.pack(side='bottom', fill='x', pady=(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=620, justify='left')
|
|
self.form_intro.pack(anchor='w', pady=(0, 8))
|
|
self.notice(f).pack(fill='x', pady=(0, 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=4, padx=(0, 10))
|
|
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=4)
|
|
b = self.button(grid, 'ui.browse', lambda n=name, p=pick_id: self.pick_file(n, p))
|
|
b.grid(row=i, column=2, padx=(8, 0))
|
|
self.form_rows[name] = lab
|
|
i = len(rows)
|
|
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=4)
|
|
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=4)
|
|
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=4)
|
|
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=4)
|
|
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(8, 0))
|
|
self.update_hint = ttk.Label(f, wraplength=620, justify='left')
|
|
self.update_hint.pack(anchor='w', pady=(8, 0))
|
|
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=620, justify='left')
|
|
self.form_error.pack(anchor='w', pady=(8, 0))
|
|
self.label(f, 'ui.keys_private', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
|
return f
|
|
|
|
def build_run(self):
|
|
f = ttk.Frame(self.body)
|
|
bar = ttk.Frame(f)
|
|
bar.pack(side='bottom', fill='x', pady=(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=('Segoe UI', 11, 'bold'))
|
|
self.run_header.pack(anchor='w')
|
|
self.step_rows = {}
|
|
self.steps_box = ttk.Frame(f)
|
|
self.steps_box.pack(fill='x', pady=(8, 8))
|
|
self.phase_label = ttk.Label(f, wraplength=620, justify='left')
|
|
self.phase_label.pack(anchor='w')
|
|
self.bar = ttk.Progressbar(f, mode='determinate', maximum=1000)
|
|
self.bar.pack(fill='x', pady=(4, 6))
|
|
self.space_label = ttk.Label(f, wraplength=620, justify='left')
|
|
self.space_label.pack(anchor='w')
|
|
self.warn_label = ttk.Label(f, wraplength=620, 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=('Consolas', 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=(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, 8))
|
|
self.back_button = self.button(bar, 'ui.back', lambda: self.show('form'))
|
|
self.back_button.pack(side='left')
|
|
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=620, justify='left')
|
|
self.finish_title.pack(anchor='w')
|
|
self.finish_text = ttk.Label(f, wraplength=620, justify='left')
|
|
self.finish_text.pack(anchor='w', pady=(8, 8))
|
|
self.finish_steps = ttk.Frame(f)
|
|
self.finish_steps.pack(fill='x', pady=(0, 8))
|
|
self.finish_notice = self.notice(f)
|
|
self.finish_notice.pack(fill='x', pady=(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(),
|
|
lang=self.selected_lang(), dest=self.vars['dest'].get())
|
|
|
|
def refresh_form(self):
|
|
form = self.form()
|
|
state = form.installed_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):
|
|
types = [(self.cat('ui.type_game'), '*.nsp *.xci'), (self.cat('ui.type_all'), '*.*')]
|
|
if name == 'update':
|
|
types = [(self.cat('ui.type_nsp'), '*.nsp'), (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_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='')
|
|
command = form.command(self.layout.python, root=self.layout.root if self.layout.dev else None,
|
|
temp=self.run_args.get('temp'), verify=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=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
|
|
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=('Segoe UI', 10, 'bold') if state == RUNNING else ('Segoe UI', 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=(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 open_folder(self):
|
|
dest = self.model.dest
|
|
if dest and Path(dest).is_dir():
|
|
os.startfile(dest)
|
|
|
|
|
|
# ---- self test ---------------------------------------------------------------------------------------------------
|
|
|
|
SAMPLE_START = {'type': 'start', 'release': 'selftest', 'mode': 'install', 'dest': 'Z:/selftest/TotK', 'log': 'D:\\log.txt',
|
|
'steps': ['check', 'extract', 'lift', 'compile', 'prepare', 'cleanup']}
|
|
SAMPLE_RUN = [
|
|
SAMPLE_START,
|
|
{'type': 'plan', 'run': ['check', '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': {'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': '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 both languages; the window closes itself. Returns 0 when nothing raised."""
|
|
fix_tcl_paths()
|
|
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)
|
|
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)
|
|
root.mainloop()
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|