Files
custom-install/ci-gui.py
2020-07-22 22:13:33 -07:00

427 lines
16 KiB
Python

# This file is a part of custom-install.py.
#
# custom-install is copyright (c) 2019-2020 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE.md in the root of this project.
from os import environ, scandir
from os.path import abspath, join, isfile, dirname
from sys import exc_info, platform
from threading import Thread, Lock
from time import strftime
from traceback import format_exception
import tkinter as tk
import tkinter.ttk as ttk
import tkinter.filedialog as fd
import tkinter.messagebox as mb
from typing import TYPE_CHECKING
from pyctr.crypto.engine import b9_paths
from pyctr.util import config_dirs
from custominstall import CustomInstall
if TYPE_CHECKING:
from typing import List
is_windows = platform == 'win32'
taskbar = None
if is_windows:
try:
import comtypes.client as cc
tbl = cc.GetModule('TaskbarLib.tlb')
taskbar = cc.CreateObject('{56FDF344-FD6D-11D0-958A-006097C9A090}', interface=tbl.ITaskbarList3)
taskbar.HrInit()
except ModuleNotFoundError:
pass
file_parent = dirname(__file__)
# automatically load boot9 if it's in the current directory
b9_paths.insert(0, join(file_parent, 'boot9.bin'))
b9_paths.insert(0, join(file_parent, 'boot9_prot.bin'))
seeddb_paths = [join(x, 'seeddb.bin') for x in config_dirs]
try:
seeddb_paths.insert(0, environ['SEEDDB_PATH'])
except KeyError:
pass
# automatically load seeddb if it's in the current directory
seeddb_paths.insert(0, join(file_parent, 'seeddb.bin'))
def find_first_file(paths):
for p in paths:
if isfile(p):
return p
# find boot9, seeddb, and movable.sed to auto-select in the gui
default_b9_path = find_first_file(b9_paths)
default_seeddb_path = find_first_file(seeddb_paths)
default_movable_sed_path = find_first_file([join(file_parent, 'movable.sed')])
class ConsoleFrame(ttk.Frame):
def __init__(self, parent: tk.BaseWidget = None, starting_lines: 'List[str]' = None):
super().__init__(parent)
self.parent = parent
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
self.text = tk.Text(self, highlightthickness=0, wrap='word', yscrollcommand=scrollbar.set)
self.text.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.config(command=self.text.yview)
if starting_lines:
for l in starting_lines:
self.text.insert(tk.END, l + '\n')
self.text.see(tk.END)
self.text.configure(state=tk.DISABLED)
def log(self, *message, end='\n', sep=' '):
self.text.configure(state=tk.NORMAL)
self.text.insert(tk.END, sep.join(message) + end)
self.text.see(tk.END)
self.text.configure(state=tk.DISABLED)
class CustomInstallGUI(ttk.Frame):
console = None
def __init__(self, parent: tk.Tk = None):
super().__init__(parent)
self.parent = parent
self.lock = Lock()
self.log_messages = []
self.hwnd = None # will be set later
self.rowconfigure(2, weight=1)
self.columnconfigure(0, weight=1)
if taskbar:
# this is so progress can be shown in the taskbar
def setup_tab():
self.hwnd = int(parent.wm_frame(), 16)
taskbar.ActivateTab(self.hwnd)
self.after(100, setup_tab)
# ---------------------------------------------------------------- #
# create file pickers for base files
file_pickers = ttk.Frame(self)
file_pickers.grid(row=0, column=0, sticky=tk.EW)
file_pickers.columnconfigure(1, weight=1)
self.file_picker_textboxes = {}
def sd_callback():
f = fd.askdirectory(parent=parent, title='Select SD root (the directory or drive that contains '
'"Nintendo 3DS")', initialdir=file_parent, mustexist=True)
if f:
sd_selected.delete('1.0', tk.END)
sd_selected.insert(tk.END, f)
sd_msed_path = find_first_file([join(f, 'gm9', 'out', 'movable.sed'), join(f, 'movable.sed')])
if sd_msed_path:
self.log('Found movable.sed on SD card at ' + sd_msed_path)
box = self.file_picker_textboxes['movable.sed']
box.delete('1.0', tk.END)
box.insert(tk.END, sd_msed_path)
sd_type_label = ttk.Label(file_pickers, text='SD root')
sd_type_label.grid(row=0, column=0)
sd_selected = tk.Text(file_pickers, wrap='none', height=1)
sd_selected.grid(row=0, column=1, sticky=tk.EW)
sd_button = ttk.Button(file_pickers, text='...', command=sd_callback)
sd_button.grid(row=0, column=2)
self.file_picker_textboxes['sd'] = sd_selected
# This feels so wrong.
def create_required_file_picker(type_name, types, default, row):
def internal_callback():
f = fd.askopenfilename(parent=parent, title='Select ' + type_name, filetypes=types,
initialdir=file_parent)
if f:
selected.delete('1.0', tk.END)
selected.insert(tk.END, f)
type_label = ttk.Label(file_pickers, text=type_name)
type_label.grid(row=row, column=0)
selected = tk.Text(file_pickers, wrap='none', height=1)
selected.grid(row=row, column=1, sticky=tk.EW)
if default:
selected.insert(tk.END, default)
button = ttk.Button(file_pickers, text='...', command=internal_callback)
button.grid(row=row, column=2)
self.file_picker_textboxes[type_name] = selected
create_required_file_picker('boot9', [('boot9 file', '*.bin')], default_b9_path, 1)
create_required_file_picker('seeddb', [('seeddb file', '*.bin')], default_seeddb_path, 2)
create_required_file_picker('movable.sed', [('movable.sed file', '*.sed')], default_movable_sed_path, 3)
# ---------------------------------------------------------------- #
# create buttons to add cias
listbox_buttons = ttk.Frame(self)
listbox_buttons.grid(row=1, column=0)
def add_cias_callback():
files = fd.askopenfilenames(parent=parent, title='Select CIA files', filetypes=[('CIA files', '*.cia')],
initialdir=file_parent)
for f in files:
self.add_cia(f)
add_cias = ttk.Button(listbox_buttons, text='Add CIAs', command=add_cias_callback)
add_cias.grid(row=0, column=0)
def add_dirs_callback():
d = fd.askdirectory(parent=parent, title='Select folder containing CIA files', initialdir=file_parent)
if d:
for f in scandir(d):
if f.name.lower().endswith('.cia'):
self.add_cia(f.path)
add_dirs = ttk.Button(listbox_buttons, text='Add folder', command=add_dirs_callback)
add_dirs.grid(row=0, column=1)
def remove_selected_callback():
indexes = self.cia_listbox.curselection()
n = 0
for i in indexes:
self.cia_listbox.delete(i - n)
n += 1
remove_selected = ttk.Button(listbox_buttons, text='Remove selected', command=remove_selected_callback)
remove_selected.grid(row=0, column=2)
# ---------------------------------------------------------------- #
# create listbox
listbox_frame = ttk.Frame(self)
listbox_frame.grid(row=2, column=0, sticky=tk.NSEW)
listbox_frame.rowconfigure(0, weight=1)
listbox_frame.columnconfigure(0, weight=1)
cia_listbox_scrollbar = ttk.Scrollbar(listbox_frame, orient=tk.VERTICAL)
cia_listbox_scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
self.cia_listbox = tk.Listbox(listbox_frame, highlightthickness=0, yscrollcommand=cia_listbox_scrollbar.set,
selectmode=tk.EXTENDED)
self.cia_listbox.grid(row=0, column=0, sticky=tk.NSEW)
cia_listbox_scrollbar.config(command=self.cia_listbox.yview)
# ---------------------------------------------------------------- #
# create progressbar
self.progressbar = ttk.Progressbar(self, orient=tk.HORIZONTAL, mode='determinate')
self.progressbar.grid(row=3, column=0, sticky=tk.NSEW)
# ---------------------------------------------------------------- #
# create start and console buttons
control_frame = ttk.Frame(self)
control_frame.grid(row=4, column=0)
self.skip_contents_var = tk.IntVar()
skip_contents_checkbox = ttk.Checkbutton(control_frame, text='Skip contents (only add to title database)',
variable=self.skip_contents_var)
skip_contents_checkbox.grid(row=0, column=0)
self.overwrite_saves_var = tk.IntVar()
overwrite_saves_checkbox = ttk.Checkbutton(control_frame, text='Overwrite existing saves',
variable=self.overwrite_saves_var)
overwrite_saves_checkbox.grid(row=0, column=1)
show_console = ttk.Button(control_frame, text='Show console', command=self.open_console)
show_console.grid(row=0, column=2)
start = ttk.Button(control_frame, text='Start install', command=self.start_install)
start.grid(row=0, column=3)
self.status_label = ttk.Label(self, text='Waiting...')
self.status_label.grid(row=5, column=0, sticky=tk.NSEW)
self.log('custom-install by ihaveamac', status=False)
self.log('https://github.com/ihaveamac/custom-install', status=False)
if is_windows and not taskbar:
self.log('Note: comtypes module not found.')
self.log('Note: Progress will not be shown in the Windows taskbar.')
self.log('Ready.')
self.disable_during_install = (add_cias, add_dirs, remove_selected, start, *self.file_picker_textboxes.values())
def add_cia(self, path):
path = abspath(path)
self.cia_listbox.insert(tk.END, path)
def open_console(self):
if self.console:
self.console.parent.lift()
self.console.focus()
else:
console_window = tk.Toplevel()
console_window.title('custom-install Console')
self.console = ConsoleFrame(console_window, self.log_messages)
self.console.pack(fill=tk.BOTH, expand=True)
def close():
with self.lock:
try:
console_window.destroy()
except:
pass
self.console = None
console_window.focus()
console_window.protocol('WM_DELETE_WINDOW', close)
def log(self, line, status=True):
with self.lock:
log_msg = f"{strftime('%H:%M:%S')} - {line}"
self.log_messages.append(log_msg)
if self.console:
self.console.log(log_msg)
if status:
self.status_label.config(text=line)
def show_error(self, message):
mb.showerror('Error', message, parent=self.parent)
def ask_warning(self, message):
return mb.askokcancel('Warning', message, parent=self.parent)
def show_info(self, message):
mb.showinfo('Info', message, parent=self.parent)
def disable_buttons(self):
for b in self.disable_during_install:
b.config(state=tk.DISABLED)
def enable_buttons(self):
for b in self.disable_during_install:
b.config(state=tk.NORMAL)
def start_install(self):
sd_root = self.file_picker_textboxes['sd'].get('1.0', tk.END).strip()
boot9 = self.file_picker_textboxes['boot9'].get('1.0', tk.END).strip()
seeddb = self.file_picker_textboxes['seeddb'].get('1.0', tk.END).strip()
movable_sed = self.file_picker_textboxes['movable.sed'].get('1.0', tk.END).strip()
if not sd_root:
self.show_error('SD root is not specified.')
return
if not boot9:
self.show_error('boot9 is not specified.')
return
if not movable_sed:
self.show_error('movable.sed is not specified.')
return
if not seeddb:
if not self.ask_warning('seeddb was not specified. Titles that require it will fail to install.\n'
'Continue?'):
return
self.disable_buttons()
self.log('Starting install...')
cias = self.cia_listbox.get(0, tk.END)
if not len(cias):
self.show_error('There are no titles added to install.')
return
installer = CustomInstall(boot9=boot9,
seeddb=seeddb,
movable=movable_sed,
cias=cias,
sd=sd_root,
skip_contents=self.skip_contents_var.get() == 1,
overwrite_saves=self.overwrite_saves_var.get() == 1)
finished_percent = 0
max_percentage = 100 * len(cias)
self.progressbar.config(maximum=max_percentage)
def ci_on_log_msg(message, *args, **kwargs):
# ignoring end
self.log(message)
def ci_update_percentage(total_percent, total_read, size):
self.progressbar.config(value=total_percent + finished_percent)
if taskbar:
taskbar.SetProgressValue(self.hwnd, int(total_percent + finished_percent), max_percentage)
def ci_on_error(exc):
if taskbar:
taskbar.SetProgressState(self.hwnd, tbl.TBPF_ERROR)
for line in format_exception(*exc):
for line2 in line.split('\n')[:-1]:
installer.log(line2)
self.show_error('An error occurred during installation.')
self.open_console()
def ci_on_cia_start(idx):
nonlocal finished_percent
finished_percent = idx * 100
if taskbar:
taskbar.SetProgressValue(self.hwnd, finished_percent, max_percentage)
installer.event.on_log_msg += ci_on_log_msg
installer.event.update_percentage += ci_update_percentage
installer.event.on_error += ci_on_error
installer.event.on_cia_start += ci_on_cia_start
if taskbar:
taskbar.SetProgressState(self.hwnd, tbl.TBPF_NORMAL)
def install():
try:
result, copied_3dsx = installer.start(continue_on_fail=False)
if result is True:
self.log('Done!')
if copied_3dsx:
self.show_info('custom-install-finalize has been copied to the SD card.\n'
'To finish the install, run this on the console through the homebrew launcher.\n'
'This will install a ticket and seed if required.')
else:
self.show_info('To finish the install, run custom-install-finalize on the console.\n'
'This will install a ticket and seed if required.')
elif result is False:
self.show_error('An error occurred when trying to run save3ds_fuse.')
self.open_console()
except:
installer.event.on_error(exc_info())
finally:
self.enable_buttons()
Thread(target=install).start()
window = tk.Tk()
window.title('custom-install')
frame = CustomInstallGUI(window)
frame.pack(fill=tk.BOTH, expand=True)
window.mainloop()