4 Commits

2 changed files with 102 additions and 33 deletions

View File

@@ -16,16 +16,18 @@ import tkinter.filedialog as fd
import tkinter.messagebox as mb import tkinter.messagebox as mb
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyctr.crypto import MissingSeedError, CryptoEngine, load_seeddb
from pyctr.crypto.engine import b9_paths from pyctr.crypto.engine import b9_paths
from pyctr.util import config_dirs from pyctr.util import config_dirs
from pyctr.type.cdn import CDNError from pyctr.type.cdn import CDNError
from pyctr.type.cia import CIAError from pyctr.type.cia import CIAError
from pyctr.type.tmd import TitleMetadataError from pyctr.type.tmd import TitleMetadataError
from custominstall import CustomInstall, CI_VERSION, load_cifinish, InvalidCIFinishError from custominstall import CustomInstall, CI_VERSION, load_cifinish, InvalidCIFinishError, InstallStatus
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Dict, List from os import PathLike
from typing import Dict, List, Union
is_windows = platform == 'win32' is_windows = platform == 'win32'
taskbar = None taskbar = None
@@ -70,6 +72,18 @@ default_b9_path = find_first_file(b9_paths)
default_seeddb_path = find_first_file(seeddb_paths) default_seeddb_path = find_first_file(seeddb_paths)
default_movable_sed_path = find_first_file([join(file_parent, 'movable.sed')]) default_movable_sed_path = find_first_file([join(file_parent, 'movable.sed')])
if default_seeddb_path:
load_seeddb(default_seeddb_path)
statuses = {
InstallStatus.Waiting: 'Waiting',
InstallStatus.Starting: 'Starting',
InstallStatus.Writing: 'Writing',
InstallStatus.Finishing: 'Finishing',
InstallStatus.Done: 'Done',
InstallStatus.Failed: 'Failed',
}
class ConsoleFrame(ttk.Frame): class ConsoleFrame(ttk.Frame):
def __init__(self, parent: tk.BaseWidget = None, starting_lines: 'List[str]' = None): def __init__(self, parent: tk.BaseWidget = None, starting_lines: 'List[str]' = None):
@@ -235,6 +249,7 @@ class InstallResults(tk.Toplevel):
class CustomInstallGUI(ttk.Frame): class CustomInstallGUI(ttk.Frame):
console = None console = None
b9_loaded = False
def __init__(self, parent: tk.Tk = None): def __init__(self, parent: tk.Tk = None):
super().__init__(parent) super().__init__(parent)
@@ -304,13 +319,14 @@ class CustomInstallGUI(ttk.Frame):
self.file_picker_textboxes['sd'] = sd_selected self.file_picker_textboxes['sd'] = sd_selected
# This feels so wrong. # This feels so wrong.
def create_required_file_picker(type_name, types, default, row): def create_required_file_picker(type_name, types, default, row, callback=lambda filename: None):
def internal_callback(): def internal_callback():
f = fd.askopenfilename(parent=parent, title='Select ' + type_name, filetypes=types, f = fd.askopenfilename(parent=parent, title='Select ' + type_name, filetypes=types,
initialdir=file_parent) initialdir=file_parent)
if f: if f:
selected.delete('1.0', tk.END) selected.delete('1.0', tk.END)
selected.insert(tk.END, f) selected.insert(tk.END, f)
callback(f)
type_label = ttk.Label(file_pickers, text=type_name) type_label = ttk.Label(file_pickers, text=type_name)
type_label.grid(row=row, column=0) type_label.grid(row=row, column=0)
@@ -325,8 +341,15 @@ class CustomInstallGUI(ttk.Frame):
self.file_picker_textboxes[type_name] = selected self.file_picker_textboxes[type_name] = selected
create_required_file_picker('boot9', [('boot9 file', '*.bin')], default_b9_path, 1) def b9_callback(path: 'Union[PathLike, bytes, str]'):
create_required_file_picker('seeddb', [('seeddb file', '*.bin')], default_seeddb_path, 2) self.check_b9_loaded()
self.enable_buttons()
def seeddb_callback(path: 'Union[PathLike, bytes, str]'):
load_seeddb(path)
create_required_file_picker('boot9', [('boot9 file', '*.bin')], default_b9_path, 1, b9_callback)
create_required_file_picker('seeddb', [('seeddb file', '*.bin')], default_seeddb_path, 2, seeddb_callback)
create_required_file_picker('movable.sed', [('movable.sed file', '*.sed')], default_movable_sed_path, 3) create_required_file_picker('movable.sed', [('movable.sed file', '*.sed')], default_movable_sed_path, 3)
# ---------------------------------------------------------------- # # ---------------------------------------------------------------- #
@@ -404,14 +427,16 @@ class CustomInstallGUI(ttk.Frame):
self.treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set) self.treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set)
self.treeview.grid(row=0, column=0, sticky=tk.NSEW) self.treeview.grid(row=0, column=0, sticky=tk.NSEW)
self.treeview.configure(columns=('filepath', 'titleid', 'titlename'), show='headings') self.treeview.configure(columns=('filepath', 'titleid', 'titlename', 'status'), show='headings')
self.treeview.column('filepath', width=200, anchor=tk.W) self.treeview.column('filepath', width=200, anchor=tk.W)
self.treeview.heading('filepath', text='File path') self.treeview.heading('filepath', text='File path')
self.treeview.column('titleid', width=50, anchor=tk.W) self.treeview.column('titleid', width=70, anchor=tk.W)
self.treeview.heading('titleid', text='Title ID') self.treeview.heading('titleid', text='Title ID')
self.treeview.column('titlename', width=150, anchor=tk.W) self.treeview.column('titlename', width=150, anchor=tk.W)
self.treeview.heading('titlename', text='Title name') self.treeview.heading('titlename', text='Title name')
self.treeview.column('status', width=20, anchor=tk.W)
self.treeview.heading('status', text='Status')
treeview_scrollbar.configure(command=self.treeview.yview) treeview_scrollbar.configure(command=self.treeview.yview)
@@ -454,7 +479,13 @@ class CustomInstallGUI(ttk.Frame):
self.log('Ready.') self.log('Ready.')
self.disable_during_install = (add_cias, add_dirs, remove_selected, start, *self.file_picker_textboxes.values()) self.require_boot9 = (add_cias, add_cdn, add_dirs, remove_selected, start)
self.disable_buttons()
self.check_b9_loaded()
self.enable_buttons()
if not self.b9_loaded:
self.log('Note: boot9 was not auto-detected. Please choose it before adding any titles.')
def sort_treeview(self): def sort_treeview(self):
l = [(self.treeview.set(k, 'titlename'), k) for k in self.treeview.get_children()] l = [(self.treeview.set(k, 'titlename'), k) for k in self.treeview.get_children()]
@@ -464,7 +495,23 @@ class CustomInstallGUI(ttk.Frame):
for idx, pair in enumerate(l): for idx, pair in enumerate(l):
self.treeview.move(pair[1], '', idx) self.treeview.move(pair[1], '', idx)
def check_b9_loaded(self):
if not self.b9_loaded:
boot9 = self.file_picker_textboxes['boot9'].get('1.0', tk.END).strip()
try:
tmp_crypto = CryptoEngine(boot9=boot9)
self.b9_loaded = tmp_crypto.b9_keys_set
except:
return False
return self.b9_loaded
def update_status(self, path: 'Union[PathLike, bytes, str]', status: InstallStatus):
self.treeview.set(path, 'status', statuses[status])
def add_cia(self, path): def add_cia(self, path):
if not self.check_b9_loaded():
# this shouldn't happen
return False, 'Please choose boot9 first'
path = abspath(path) path = abspath(path)
if path in self.readers: if path in self.readers:
return False, 'File already in list' return False, 'File already in list'
@@ -472,6 +519,8 @@ class CustomInstallGUI(ttk.Frame):
reader = CustomInstall.get_reader(path) reader = CustomInstall.get_reader(path)
except (CIAError, CDNError, TitleMetadataError): except (CIAError, CDNError, TitleMetadataError):
return False, 'Failed to read as a CIA or CDN title, probably corrupt' return False, 'Failed to read as a CIA or CDN title, probably corrupt'
except MissingSeedError:
return False, 'Latest seeddb.bin is required, check the README for details'
except Exception as e: except Exception as e:
return False, f'Exception occurred: {type(e).__name__}: {e}' return False, f'Exception occurred: {type(e).__name__}: {e}'
@@ -481,7 +530,8 @@ class CustomInstallGUI(ttk.Frame):
title_name = reader.contents[0].exefs.icon.get_app_title().short_desc title_name = reader.contents[0].exefs.icon.get_app_title().short_desc
except: except:
title_name = '(No title)' title_name = '(No title)'
self.treeview.insert('', tk.END, text=path, iid=path, values=(path, reader.tmd.title_id, title_name)) self.treeview.insert('', tk.END, text=path, iid=path,
values=(path, reader.tmd.title_id, title_name, statuses[InstallStatus.Waiting]))
self.readers[path] = reader self.readers[path] = reader
return True, '' return True, ''
@@ -532,25 +582,26 @@ class CustomInstallGUI(ttk.Frame):
mb.showinfo('Info', message, parent=self.parent) mb.showinfo('Info', message, parent=self.parent)
def disable_buttons(self): def disable_buttons(self):
for b in self.disable_during_install: for b in self.require_boot9:
b.config(state=tk.DISABLED)
for b in self.file_picker_textboxes.values():
b.config(state=tk.DISABLED) b.config(state=tk.DISABLED)
def enable_buttons(self): def enable_buttons(self):
for b in self.disable_during_install: if self.b9_loaded:
for b in self.require_boot9:
b.config(state=tk.NORMAL)
for b in self.file_picker_textboxes.values():
b.config(state=tk.NORMAL) b.config(state=tk.NORMAL)
def start_install(self): def start_install(self):
sd_root = self.file_picker_textboxes['sd'].get('1.0', tk.END).strip() 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() 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() movable_sed = self.file_picker_textboxes['movable.sed'].get('1.0', tk.END).strip()
if not sd_root: if not sd_root:
self.show_error('SD root is not specified.') self.show_error('SD root is not specified.')
return return
if not boot9:
self.show_error('boot9 is not specified.')
return
if not movable_sed: if not movable_sed:
self.show_error('movable.sed is not specified.') self.show_error('movable.sed is not specified.')
return return
@@ -560,29 +611,28 @@ class CustomInstallGUI(ttk.Frame):
'Continue?'): 'Continue?'):
return return
self.disable_buttons()
if not len(self.readers): if not len(self.readers):
self.show_error('There are no titles added to install.') self.show_error('There are no titles added to install.')
return return
for path in self.readers.keys():
self.update_status(path, InstallStatus.Waiting)
self.disable_buttons()
self.log('Starting install...') self.log('Starting install...')
if taskbar: if taskbar:
taskbar.SetProgressState(self.hwnd, tbl.TBPF_NORMAL) taskbar.SetProgressState(self.hwnd, tbl.TBPF_NORMAL)
installer = CustomInstall(boot9=boot9, installer = CustomInstall(movable=movable_sed,
seeddb=seeddb,
movable=movable_sed,
sd=sd_root, sd=sd_root,
skip_contents=self.skip_contents_var.get() == 1, skip_contents=self.skip_contents_var.get() == 1,
overwrite_saves=self.overwrite_saves_var.get() == 1) overwrite_saves=self.overwrite_saves_var.get() == 1)
# use the treeview which has been sorted alphabetically # use the treeview which has been sorted alphabetically
#installer.readers = self.readers.values()
readers_final = [] readers_final = []
for k in self.treeview.get_children(): for k in self.treeview.get_children():
readers_final.append(self.readers[self.treeview.set(k, 'filepath')]) filepath = self.treeview.set(k, 'filepath')
readers_final.append((self.readers[filepath], filepath))
installer.readers = readers_final installer.readers = readers_final
@@ -618,6 +668,7 @@ class CustomInstallGUI(ttk.Frame):
installer.event.update_percentage += ci_update_percentage installer.event.update_percentage += ci_update_percentage
installer.event.on_error += ci_on_error installer.event.on_error += ci_on_error
installer.event.on_cia_start += ci_on_cia_start installer.event.on_cia_start += ci_on_cia_start
installer.event.update_status += self.update_status
if self.skip_contents_var.get() != 1: if self.skip_contents_var.get() != 1:
total_size, free_space = installer.check_size() total_size, free_space = installer.check_size()

View File

@@ -5,6 +5,7 @@
# You can find the full license text in LICENSE.md in the root of this project. # You can find the full license text in LICENSE.md in the root of this project.
from argparse import ArgumentParser from argparse import ArgumentParser
from enum import Enum
from os import makedirs, rename, scandir from os import makedirs, rename, scandir
from os.path import dirname, join, isdir, isfile from os.path import dirname, join, isdir, isfile
from random import randint from random import randint
@@ -21,7 +22,7 @@ import subprocess
if TYPE_CHECKING: if TYPE_CHECKING:
from os import PathLike from os import PathLike
from typing import List, Union from typing import List, Union, Tuple
from events import Events from events import Events
@@ -42,7 +43,7 @@ if is_windows:
else: else:
from os import statvfs from os import statvfs
CI_VERSION = '2.1b1' CI_VERSION = '2.1b2'
# used to run the save3ds_fuse binary next to the script # used to run the save3ds_fuse binary next to the script
frozen = getattr(sys, 'frozen', False) frozen = getattr(sys, 'frozen', False)
@@ -74,6 +75,15 @@ class InvalidCIFinishError(Exception):
pass pass
class InstallStatus(Enum):
Waiting = 0
Starting = 1
Writing = 2
Finishing = 3
Done = 4
Failed = 5
def get_free_space(path: 'Union[PathLike, bytes, str]'): def get_free_space(path: 'Union[PathLike, bytes, str]'):
if is_windows: if is_windows:
lpSectorsPerCluster = c_ulonglong(0) lpSectorsPerCluster = c_ulonglong(0)
@@ -191,15 +201,15 @@ def get_install_size(title: 'Union[CIAReader, CDNReader]'):
class CustomInstall: class CustomInstall:
def __init__(self, boot9, seeddb, movable, sd, cifinish_out=None, def __init__(self, *, movable, sd, cifinish_out=None, overwrite_saves=False, skip_contents=False,
overwrite_saves=False, skip_contents=False): boot9=None, seeddb=None):
self.event = Events() self.event = Events()
self.log_lines = [] # Stores all info messages for user to view self.log_lines = [] # Stores all info messages for user to view
self.crypto = CryptoEngine(boot9=boot9) self.crypto = CryptoEngine(boot9=boot9)
self.crypto.setup_sd_key_from_file(movable) self.crypto.setup_sd_key_from_file(movable)
self.seeddb = seeddb self.seeddb = seeddb
self.readers: 'List[Union[CDNReader, CIAReader]]' = [] self.readers: 'List[Tuple[Union[CDNReader, CIAReader], Union[PathLike, bytes, str]]]' = []
self.sd = sd self.sd = sd
self.skip_contents = skip_contents self.skip_contents = skip_contents
self.overwrite_saves = overwrite_saves self.overwrite_saves = overwrite_saves
@@ -249,12 +259,12 @@ class CustomInstall:
if reader.tmd.title_id.startswith('00048'): # DSiWare if reader.tmd.title_id.startswith('00048'): # DSiWare
self.log(f'Skipping {reader.tmd.title_id} - DSiWare is not supported') self.log(f'Skipping {reader.tmd.title_id} - DSiWare is not supported')
continue continue
readers.append(reader) readers.append((reader, path))
self.readers = readers self.readers = readers
def check_size(self): def check_size(self):
total_size = 0 total_size = 0
for r in self.readers: for r, _ in self.readers:
total_size += get_install_size(r) total_size += get_install_size(r)
free_space = get_free_space(self.sd) free_space = get_free_space(self.sd)
@@ -329,14 +339,17 @@ class CustomInstall:
sd_path = join(sd_path, id1s[0]) sd_path = join(sd_path, id1s[0])
if self.seeddb:
load_seeddb(self.seeddb) load_seeddb(self.seeddb)
install_state = {'installed': [], 'failed': []} install_state = {'installed': [], 'failed': []}
# Now loop through all provided cia files # Now loop through all provided cia files
for idx, cia in enumerate(self.readers): for idx, info in enumerate(self.readers):
cia, path = info
self.event.on_cia_start(idx) self.event.on_cia_start(idx)
self.event.update_status(path, InstallStatus.Starting)
temp_title_root = join(self.sd, f'ci-install-temp-{cia.tmd.title_id}-{randint(0, 0xFFFFFFFF):08x}') temp_title_root = join(self.sd, f'ci-install-temp-{cia.tmd.title_id}-{randint(0, 0xFFFFFFFF):08x}')
makedirs(temp_title_root, exist_ok=True) makedirs(temp_title_root, exist_ok=True)
@@ -383,6 +396,7 @@ class CustomInstall:
temp_content_root = join(temp_title_root, 'content') temp_content_root = join(temp_title_root, 'content')
if not self.skip_contents: if not self.skip_contents:
self.event.update_status(path, InstallStatus.Writing)
makedirs(join(temp_content_root, 'cmd'), exist_ok=True) makedirs(join(temp_content_root, 'cmd'), exist_ok=True)
if cia.tmd.save_size: if cia.tmd.save_size:
makedirs(join(temp_title_root, 'data'), exist_ok=True) makedirs(join(temp_title_root, 'data'), exist_ok=True)
@@ -423,6 +437,7 @@ class CustomInstall:
install_state['failed'].append(display_title) install_state['failed'].append(display_title)
rename(temp_title_root, temp_title_root + '-corrupted') rename(temp_title_root, temp_title_root + '-corrupted')
do_continue = True do_continue = True
self.event.update_status(path, InstallStatus.Failed)
break break
if do_continue: if do_continue:
@@ -534,6 +549,7 @@ class CustomInstall:
b'\0' * 0x2c b'\0' * 0x2c
] ]
self.event.update_status(path, InstallStatus.Finishing)
if isdir(title_root): if isdir(title_root):
self.log(f'Removing original install at {title_root}...') self.log(f'Removing original install at {title_root}...')
rmtree(title_root) rmtree(title_root)
@@ -563,8 +579,10 @@ class CustomInstall:
for l in pformat(out.args).split('\n'): for l in pformat(out.args).split('\n'):
self.log(l) self.log(l)
install_state['failed'].append(display_title) install_state['failed'].append(display_title)
self.event.update_status(path, InstallStatus.Failed)
else:
install_state['installed'].append(display_title) install_state['installed'].append(display_title)
self.event.update_status(path, InstallStatus.Done)
copied = False copied = False
if install_state['installed']: if install_state['installed']: