94 Commits

Author SHA1 Message Date
Ian Burgwin
46a0d985a7 vesion 2.1b2 2021-02-12 20:58:13 -08:00
Ian Burgwin
37112682a0 show mid-install status per-title 2021-02-09 21:21:34 -08:00
Ian Burgwin
9c777adf26 ci-gui: ensure boot9 is loaded before allowing titles to be added, show better error message for missing seeddb, load seeddb before starting and any time a new one is selected 2021-02-09 20:24:34 -08:00
Ian Burgwin
b3eae08f27 custominstall: make boot9 and seeddb optional for CustomInstall.__init__ (for gui changes) 2021-02-09 20:22:24 -08:00
Ian Burgwin
5f49493dfb vesion 2.1b1 2021-02-09 15:41:02 -08:00
Ian Burgwin
fbc553f5c7 README: update description 2021-02-09 15:23:34 -08:00
Ian Burgwin
68d9026524 LICENSE: update copyright year 2021-02-09 15:16:44 -08:00
Ian Burgwin
46ac9cd809 ci-gui: show OK button in TitleFailReadResults and InstallResults windows 2021-02-09 14:49:28 -08:00
Ian Burgwin
40a8d2d684 ci-gui: prevent adding a file twice 2021-02-09 14:48:38 -08:00
Ian Burgwin
4ec5bce712 ci-gui: sort treeview by title name 2021-02-09 14:48:07 -08:00
Ian Burgwin
d27e181c40 ci-gui: show error reason for cdn titles 2021-02-09 00:01:59 -08:00
Ian Burgwin
8ed6ca54cc ci-gui: replace listview with treeview, load titles before adding to list and show reasons for failure, verify cifinish.bin after choosing SD card 2021-02-08 23:45:26 -08:00
Ian Burgwin
0dcaaedda7 custominstall: separate reader creation to get_reader, check against more errors 2021-02-08 23:43:40 -08:00
Ian Burgwin
f904049c06 custominstall: stop if cifinish.bin is corrupt 2021-02-08 23:43:13 -08:00
Ian Burgwin
7b121f5212 ci-gui: make InstallResults transient, adjust listbox frame height based on item count 2021-02-08 21:27:55 -08:00
Ian Burgwin
1b2b0d06db custominstall: block DSiWare from installing 2021-02-08 21:23:57 -08:00
Ian Burgwin
6623ffb439 ci-gui: show version in window title and console 2021-02-08 21:23:36 -08:00
Ian Burgwin
4733997132 custominstall: add CI_VERSION constant, print version at start 2021-02-08 21:22:06 -08:00
Ian Burgwin
9fc509489f ci-gui: enable buttons if an error occurs before installation 2021-02-08 20:55:51 -08:00
Ian Burgwin
2636c5923c custominstall: remove "Manually" from ArgumentParser description 2021-02-08 20:41:45 -08:00
Ian Burgwin
cfa46abea5 requirements: remove pycryptodome requirement (not used directly, only through pyctr) 2021-02-08 20:26:02 -08:00
Ian Burgwin
d91c567fc5 ci-gui: show (incomplete) more detailed install results window 2021-02-08 14:42:06 -08:00
Ian Burgwin
188be9b9d6 ci-gui: check for free space before installing 2021-02-08 14:41:30 -08:00
Ian Burgwin
616f9031b2 custominstall: check for free space before installing, move title size calculation to separate function 2021-02-08 14:39:50 -08:00
Ian Burgwin
b8bd9371dd finalize: delete cifinish if successful 2021-02-08 12:30:10 -08:00
Ian Burgwin
b69dfb0a46 finalize: close cifinish if successful 2021-02-08 12:29:35 -08:00
Ian Burgwin
e0573809bb custominstall: always use randomized temp install dir 2020-12-02 22:42:05 -08:00
Ian Burgwin
46ce6ab76c custominstall: verify content hashes 2020-12-02 22:29:53 -08:00
Ian Burgwin
fcf47e0564 ci-gui: prioritize failed message over installed 2020-12-02 22:29:20 -08:00
Ian Burgwin
a529ecf760 ci-gui: show list of installed and failed titles 2020-12-02 22:13:17 -08:00
Ian Burgwin
793d923240 custominstall: keep track of successful and failed installs 2020-12-02 22:13:01 -08:00
Ian Burgwin
918111dedf custominstall: remove debug line 2020-12-02 21:50:19 -08:00
Ian Burgwin
47f22313b4 custominstall: import into title.db after every title install 2020-12-02 21:49:38 -08:00
Ian Burgwin
5d60715d94 custominstall: write to temporary directory first, then move into place 2020-12-02 21:12:06 -08:00
Ian Burgwin
aad1accca3 custominstall: extract title.db before installing (untested)
This should help prevent installing a bunch of titles, only to find title.db doesn't exist.
2020-12-02 20:26:53 -08:00
Ian Burgwin
945b0a377b custominstall: don't fail if cifinish.bin is corrupted 2020-12-02 20:17:50 -08:00
Ian Burgwin
707b852db3 requirements: update versions 2020-12-02 20:08:52 -08:00
Ian Burgwin
794eb8750f custominstall: fix incorrect return value causing TypeError in ci-gui 2020-10-14 17:37:05 -07:00
Ian Burgwin
b34bba2543 ci-gui: print command line args if save3ds_fuse fails 2020-09-03 19:45:11 -07:00
Ian Burgwin
40cfd955cc custominstall: set platform to win32 if it's msys (close #2) 2020-07-28 09:32:18 -07:00
Ian Burgwin
bbcfb6fef1 ci-gui: support adding CDN folders 2020-07-28 06:33:18 -07:00
Ian Burgwin
1e3e15c969 custominstall: support CDN contents (close #27) 2020-07-28 06:21:22 -07:00
Ian Burgwin
48f92579ce custominstall: use bytes(cia.tmd) instead of reading tmd file directly 2020-07-28 06:15:27 -07:00
Ian Burgwin
06f70e37dc requirements: bump pyctr to 0.4.3 2020-07-28 06:01:13 -07:00
Ian Burgwin
44787ebc87 requirements: bump pyctr to 0.4.2 2020-07-28 04:51:03 -07:00
Ian Burgwin
399bb97238 custominstall: read all CIAs before installing
Prevents the issue of one having a corrupt header or something causing an issue in the middle of writing.
2020-07-28 02:33:08 -07:00
Ian Burgwin
6da2ed3343 ci-gui: use abspath when getting file parent
This should the initial directory setting for file dialogs work more reliably.
2020-07-28 01:29:59 -07:00
Ian Burgwin
00202c473e make-standalone: fix copying LICENSE.md 2020-07-22 23:05:34 -07:00
Ian Burgwin
c45c082bfb add extras/windows-quickstart.txt, update make-standalone, update README for standalone release steps 2020-07-22 22:37:27 -07:00
Ian Burgwin
49fb0f832f remove docu folder with old image 2020-07-22 22:21:51 -07:00
Ian Burgwin
a725d876de ci-gui: make seeddb optional 2020-07-22 22:13:33 -07:00
Ian Burgwin
a26579ec69 custominstall: fix NameError (wrong variable name) 2020-07-22 22:03:55 -07:00
Ian Burgwin
4296bf3ea6 README: update standalone build steps 2020-07-22 21:49:22 -07:00
Ian Burgwin
3006989fc6 ci-gui: show different dialog if 3dsx was copied 2020-07-22 21:44:49 -07:00
Ian Burgwin
19045d8b87 custominstall: copy finalize 3dsx if found next to script/exe 2020-07-22 21:44:26 -07:00
Ian Burgwin
20a829904b make-standalone: copy 3dsx to build dir 2020-07-22 21:41:33 -07:00
Ian Burgwin
0741e4b5eb ci-gui: don't attempt to install if no titles are added 2020-07-22 21:31:45 -07:00
Ian Burgwin
bc7f20361c make-standalone: make dist directory 2020-07-22 20:48:35 -07:00
Ian Burgwin
d176d1f0ee update make-standalone (untested) 2020-07-22 20:46:14 -07:00
Ian Burgwin
449ee90311 README: update setup to mention windows-install-dependencies 2020-07-22 20:44:57 -07:00
Ian Burgwin
10c0d9a23a add windows-install-dependencies.py 2020-07-22 20:44:22 -07:00
Ian Burgwin
034458b3fc README: add 32-bit notice 2020-07-22 20:21:14 -07:00
Ian Burgwin
2dd6caf128 make-standalone: add missing --base-name= 2020-07-22 19:34:14 -07:00
Ian Burgwin
7ee6999725 README: add Development -> Building Windows standalone 2020-07-22 19:32:10 -07:00
Ian Burgwin
609a0de18b add make-standalone.bat 2020-07-22 19:31:56 -07:00
Ian Burgwin
8aa4fa4ddc ci-gui: remove unused code 2020-07-22 18:46:44 -07:00
Ian Burgwin
bd9150ed66 requirements: set fixed versions for pycryptodomex and comtypes 2020-07-22 18:25:11 -07:00
Ian Burgwin
cd86713d17 ci-gui: make comtypes an optional module 2020-07-22 16:16:10 -07:00
Ian Burgwin
bea3c3c082 ci-gui: add checkbox for overwriting saves 2020-07-22 16:14:55 -07:00
Ian Burgwin
9fc04a490e custominstall: don't overwrite existing save by default (close #26) 2020-07-22 16:04:59 -07:00
Ian Burgwin
7707a67048 custominstall: hide console window for save3ds_fuse 2020-07-20 01:46:38 -07:00
Ian Burgwin
88520570af custominstall: fix save3ds_fuse lookup 2020-07-19 22:58:52 -07:00
Ian Burgwin
53fd45790f custominstall: look for bin/save3ds_fuse next to executable if sys.frozen exists (from cx_Freeze) 2020-07-19 22:38:39 -07:00
Ian Burgwin
56747d36eb custominstall: exit early if save3ds_fuse can't be found 2020-07-19 22:29:33 -07:00
Ian Burgwin
4522c009c3 add TaskbarLib.tlb 2020-07-19 21:37:42 -07:00
Ian Burgwin
a58bfa4ae1 implement new gui 2020-07-19 20:34:22 -07:00
Ian Burgwin
187e27fc95 custominstall: support new events, check if save3ds_fuse runs successfully, fix seed loading, don't continue if cia fails to load (temp maybe) 2020-07-19 20:30:34 -07:00
Ian Burgwin
ed7fc99ff1 gui: properly print args on exception in execute_script 2020-07-12 21:16:25 -07:00
Ian Burgwin
3a5f554b58 gui: add full path when adding folder (close #23) 2020-07-12 21:12:59 -07:00
Ian Burgwin
ba5c5f19a7 Merge remote-tracking branch 'origin/module-new-gui' into module-new-gui 2020-07-12 21:08:15 -07:00
Ian Burgwin
c344ce3e7b allow changing cifinish output path 2020-07-12 21:07:51 -07:00
Ian Burgwin
13f706a0dc update to pyctr 0.4.1, catch exceptions when creating CIAReader 2020-07-12 21:07:26 -07:00
Ian Burgwin
3c99c7a9d9 Merge pull request #24 from uaevuon/module-new-gui
Change the order and the link in readme
2020-06-30 15:14:59 -07:00
uaevuon
238b7400e0 Change the order and link 2020-06-30 00:17:41 +09:00
Ian Burgwin
2319819bfa README: add notice about "Add Python 3.x to PATH" 2020-06-29 06:02:50 -07:00
Ian Burgwin
cb52b38ea7 custominstall: only makedirs if --skip-contents is not used, add exist_ok=True for DLC directories 2020-06-24 19:44:19 -07:00
Ian Burgwin
3dcee32145 Merge remote-tracking branch 'origin/module-new-gui' into module-new-gui 2020-06-24 19:41:59 -07:00
Ian Burgwin
647f21d32b Merge pull request #22 from LyfeOnEdge/patch-2
Clean up comments
2020-06-20 19:36:57 -07:00
Andrew (LyfeOnEdge) (ArcticGentoo)
58237a0ebe Clean up comments 2020-06-18 20:56:45 -07:00
Ian Burgwin
9f69a2195c Merge pull request #20 from JustSch/module-new-gui
added seeddb option to gui
2020-06-05 17:11:52 -07:00
Justin Schreiber
91e0fa24ad added seeddb option to gui 2020-05-27 19:34:11 -04:00
Ian Burgwin
b3365c47bd custominstall: don't extract or rebuild title.db if no titles were installed 2020-05-25 19:26:02 -07:00
Ian Burgwin
a515ca7e61 finalize: fix using pointers incorrectly, meaning only one title would work 2020-05-23 11:02:25 -07:00
Ian Burgwin
167a80ff11 gui: use list for args instead of creating a string 2020-05-22 02:43:11 -07:00
14 changed files with 1266 additions and 719 deletions

View File

@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2019 Ian Burgwin Copyright (c) 2019-2021 Ian Burgwin
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,14 +1,25 @@
[![License](https://img.shields.io/badge/License-MIT-blue.svg)]() ![Releases](https://img.shields.io/github/downloads/ihaveamac/custom-install/total.svg) [![License](https://img.shields.io/badge/License-MIT-blue.svg)]() ![Releases](https://img.shields.io/github/downloads/ihaveamac/custom-install/total.svg)
# custom-install # custom-install
Experimental script to automate the process of a manual title install for Nintendo 3DS. Originally created late June 2019. Installs a title directly to an SD card for the Nintendo 3DS. Originally created late June 2019.
## Summary ## Summary
### Windows standalone
1. [Dump boot9.bin and movable.sed](https://ihaveamac.github.io/dump.html) from a 3DS system. 1. [Dump boot9.bin and movable.sed](https://ihaveamac.github.io/dump.html) from a 3DS system.
2. Install the packages: 2. Download the [latest releases](https://github.com/ihaveamac/custom-install/releases).
* Windows: `py -3 -m pip install --user -r requirements.txt` 3. Extract and run ci-gui. Read `windows-quickstart.txt`.
### With installed Python
Note for Windows users: Enabling "Add Python 3.X to PATH" is **NOT** required! Python is installed with the `py` launcher by default.
1. [Dump boot9.bin and movable.sed](https://ihaveamac.github.io/dump.html) from a 3DS system.
2. Download the repo ([zip link](https://github.com/ihaveamac/custom-install/archive/module-newer-gui.zip) or `git clone`)
3. Install the packages:
* Windows: Double-click `windows-install-dependencies.py`
* Alternate manual method: `py -3 -m pip install --user -r requirements-win32.txt`
* macOS/Linux: `python3 -m pip install --user -r requirements.txt` * macOS/Linux: `python3 -m pip install --user -r requirements.txt`
3. Download the repo ([zip link](https://github.com/ihaveamac/custom-install/archive/master.zip) or `git clone`)
4. Run `custominstall.py` with boot9.bin, movable.sed, path to the SD root, and CIA files to install (see Usage section). 4. Run `custominstall.py` with boot9.bin, movable.sed, path to the SD root, and CIA files to install (see Usage section).
5. Download and use [custom-install-finalize](https://github.com/ihaveamac/custom-install/releases) on the 3DS system to finish the install. 5. Download and use [custom-install-finalize](https://github.com/ihaveamac/custom-install/releases) on the 3DS system to finish the install.
@@ -50,21 +61,37 @@ python3 custominstall.py -b boot9.bin -m movable.sed --sd /media/GM9SD file.cia
``` ```
## GUI ## GUI
GUI wrapper to easily manage your apps. (More will go here...) A GUI is provided to make the process easier.
![GUI](https://raw.githubusercontent.com/LyfeOnEdge/custom-install/master/docu/main.png)
### GUI Setup ### GUI Setup
Linux users may need to install a Tk package:
- Ubuntu/Debian: `sudo apt install python3-tk` - Ubuntu/Debian: `sudo apt install python3-tk`
- Manjaro/Arch: `sudo pacman -S tk` - Manjaro/Arch: `sudo pacman -S tk`
- Mac: Sometimes the default tkinter libs that ship with mac don't work, you can get them on the python site - `https://www.python.org/downloads/mac-osx/`
- Windows: Install python - `Remember to install tcl/tk when doing a custom installation` Install the requirements listed in "Summary", then run `ci-gui.py`.
## Development
### Building Windows standalone
Using a 32-bit version of Python is recommended to build a version to be distributed.
A [virtual environment](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) is recommended to isolate the packages from system directories. The build script `make-standalone.bat` assumes that the dependencies are in PATH.
Install the dependencies, plus cx-Freeze. In a virtual environment, the specific Python version doesn't need to be requested.
```batch
pip install cx-freeze -r requirements-win32.txt
```
Copy `custom-install-finalize.3dsx` to the project root, this will be copied to the build directory and included in the final archive.
Run `make-standalone.bat`. This will run cxfreeze and make a standalone version at `dist\custom-install-standalone.zip`
## License/Credits ## License/Credits
[save3ds by wwylele](https://github.com/wwylele/save3ds) is used to interact with the Title Database (details in `bin/README`). [save3ds by wwylele](https://github.com/wwylele/save3ds) is used to interact with the Title Database (details in `bin/README`).
Thanks to @LyfeOnEdge from the [brewtools Discord](https://brewtools.dev) for designing the GUI. Special thanks to CrafterPika and archbox for testing.
Thanks to @nek0bit for redesigning `custominstall.py` to work as a module, and for implementing an earlier GUI. Thanks to @nek0bit for redesigning `custominstall.py` to work as a module, and for implementing an earlier GUI.
Thanks to @LyfeOnEdge from the [brewtools Discord](https://brewtools.dev) for designing the second version of the GUI. Special thanks to CrafterPika and archbox for testing.
Thanks to @BpyH64 for [researching how to generate the cmacs](https://github.com/d0k3/GodMode9/issues/340#issuecomment-487916606). Thanks to @BpyH64 for [researching how to generate the cmacs](https://github.com/d0k3/GodMode9/issues/340#issuecomment-487916606).

BIN
TaskbarLib.tlb Normal file

Binary file not shown.

704
ci-gui.py Normal file
View File

@@ -0,0 +1,704 @@
# 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, basename, dirname, join, isfile
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 import MissingSeedError, CryptoEngine, load_seeddb
from pyctr.crypto.engine import b9_paths
from pyctr.util import config_dirs
from pyctr.type.cdn import CDNError
from pyctr.type.cia import CIAError
from pyctr.type.tmd import TitleMetadataError
from custominstall import CustomInstall, CI_VERSION, load_cifinish, InvalidCIFinishError, InstallStatus
if TYPE_CHECKING:
from os import PathLike
from typing import Dict, List, Union
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(abspath(__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 clamp(n, smallest, largest):
return max(smallest, min(n, largest))
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')])
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):
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)
def simple_listbox_frame(parent, title: 'str', items: 'List[str]'):
frame = ttk.LabelFrame(parent, text=title)
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL)
scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
box = tk.Listbox(frame, highlightthickness=0, yscrollcommand=scrollbar.set, selectmode=tk.EXTENDED)
box.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.config(command=box.yview)
box.insert(tk.END, *items)
box.config(height=clamp(len(items), 3, 10))
return frame
class TitleReadFailResults(tk.Toplevel):
def __init__(self, parent: tk.Tk = None, *, failed: 'Dict[str, str]'):
super().__init__(parent)
self.parent = parent
self.wm_withdraw()
self.wm_transient(self.parent)
self.grab_set()
self.wm_title('Failed to add titles')
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
outer_container = ttk.Frame(self)
outer_container.grid(sticky=tk.NSEW)
outer_container.rowconfigure(0, weight=0)
outer_container.rowconfigure(1, weight=1)
outer_container.columnconfigure(0, weight=1)
message_label = ttk.Label(outer_container, text="Some titles couldn't be added.")
message_label.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10)
treeview_frame = ttk.Frame(outer_container)
treeview_frame.grid(row=1, column=0, sticky=tk.NSEW)
treeview_frame.rowconfigure(0, weight=1)
treeview_frame.columnconfigure(0, weight=1)
treeview_scrollbar = ttk.Scrollbar(treeview_frame, orient=tk.VERTICAL)
treeview_scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set)
treeview.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
treeview.configure(columns=('filepath', 'reason'), show='headings')
treeview.column('filepath', width=200, anchor=tk.W)
treeview.heading('filepath', text='File path')
treeview.column('reason', width=400, anchor=tk.W)
treeview.heading('reason', text='Reason')
treeview_scrollbar.configure(command=treeview.yview)
for path, reason in failed.items():
treeview.insert('', tk.END, text=path, iid=path, values=(basename(path), reason))
ok_frame = ttk.Frame(outer_container)
ok_frame.grid(row=2, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
ok_frame.rowconfigure(0, weight=1)
ok_frame.columnconfigure(0, weight=1)
ok_button = ttk.Button(ok_frame, text='OK', command=self.destroy)
ok_button.grid(row=0, column=0)
self.wm_deiconify()
class InstallResults(tk.Toplevel):
def __init__(self, parent: tk.Tk = None, *, install_state: 'Dict[str, List[str]]', copied_3dsx: bool):
super().__init__(parent)
self.parent = parent
self.wm_withdraw()
self.wm_transient(self.parent)
self.grab_set()
self.wm_title('Install results')
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
outer_container = ttk.Frame(self)
outer_container.grid(sticky=tk.NSEW)
outer_container.rowconfigure(0, weight=0)
outer_container.columnconfigure(0, weight=1)
if install_state['failed'] and install_state['installed']:
# some failed and some worked
message = ('Some titles were installed, some failed. Please check the output for more details.\n'
'The ones that were installed can be finished with custom-install-finalize.')
elif install_state['failed'] and not install_state['installed']:
# all failed
message = 'All titles failed to install. Please check the output for more details.'
elif install_state['installed'] and not install_state['failed']:
# all worked
message = 'All titles were installed.'
else:
message = 'Nothing was installed.'
if install_state['installed'] and copied_3dsx:
message += '\n\ncustom-install-finalize has been copied to the SD card.'
message_label = ttk.Label(outer_container, text=message)
message_label.grid(row=0, column=0, sticky=tk.NSEW, padx=10, pady=10)
if install_state['installed']:
outer_container.rowconfigure(1, weight=1)
frame = simple_listbox_frame(outer_container, 'Installed', install_state['installed'])
frame.grid(row=1, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
if install_state['failed']:
outer_container.rowconfigure(2, weight=1)
frame = simple_listbox_frame(outer_container, 'Failed', install_state['failed'])
frame.grid(row=2, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
ok_frame = ttk.Frame(outer_container)
ok_frame.grid(row=3, column=0, sticky=tk.NSEW, padx=10, pady=(0, 10))
ok_frame.rowconfigure(0, weight=1)
ok_frame.columnconfigure(0, weight=1)
ok_button = ttk.Button(ok_frame, text='OK', command=self.destroy)
ok_button.grid(row=0, column=0)
self.wm_deiconify()
class CustomInstallGUI(ttk.Frame):
console = None
b9_loaded = False
def __init__(self, parent: tk.Tk = None):
super().__init__(parent)
self.parent = parent
# readers to give to CustomInstall at the install
self.readers = {}
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:
cifinish_path = join(f, 'cifinish.bin')
try:
load_cifinish(cifinish_path)
except InvalidCIFinishError:
self.show_error(f'{cifinish_path} was corrupt!\n\n'
f'This could mean an issue with the SD card or the filesystem. Please check it for errors.\n'
f'It is also possible, though less likely, to be an issue with custom-install.\n\n'
f'Stopping now to prevent possible issues. If you want to try again, delete cifinish.bin from the SD card and re-run custom-install.')
return
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, callback=lambda filename: None):
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)
callback(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
def b9_callback(path: 'Union[PathLike, bytes, str]'):
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 buttons to add cias
titlelist_buttons = ttk.Frame(self)
titlelist_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)
results = {}
for f in files:
success, reason = self.add_cia(f)
if not success:
results[f] = reason
if results:
title_read_fail_window = TitleReadFailResults(self.parent, failed=results)
title_read_fail_window.focus()
self.sort_treeview()
add_cias = ttk.Button(titlelist_buttons, text='Add CIAs', command=add_cias_callback)
add_cias.grid(row=0, column=0)
def add_cdn_callback():
d = fd.askdirectory(parent=parent, title='Select folder containing title contents in CDN format',
initialdir=file_parent)
if d:
if isfile(join(d, 'tmd')):
success, reason = self.add_cia(d)
if not success:
self.show_error(f"Couldn't add {basename(d)}: {reason}")
else:
self.sort_treeview()
else:
self.show_error('tmd file not found in the CDN directory:\n' + d)
add_cdn = ttk.Button(titlelist_buttons, text='Add CDN title folder', command=add_cdn_callback)
add_cdn.grid(row=0, column=1)
def add_dirs_callback():
d = fd.askdirectory(parent=parent, title='Select folder containing CIA files', initialdir=file_parent)
if d:
results = {}
for f in scandir(d):
if f.name.lower().endswith('.cia'):
success, reason = self.add_cia(f.path)
if not success:
results[f] = reason
if results:
title_read_fail_window = TitleReadFailResults(self.parent, failed=results)
title_read_fail_window.focus()
self.sort_treeview()
add_dirs = ttk.Button(titlelist_buttons, text='Add folder', command=add_dirs_callback)
add_dirs.grid(row=0, column=2)
def remove_selected_callback():
for entry in self.treeview.selection():
self.remove_cia(entry)
remove_selected = ttk.Button(titlelist_buttons, text='Remove selected', command=remove_selected_callback)
remove_selected.grid(row=0, column=3)
# ---------------------------------------------------------------- #
# create treeview
treeview_frame = ttk.Frame(self)
treeview_frame.grid(row=2, column=0, sticky=tk.NSEW)
treeview_frame.rowconfigure(0, weight=1)
treeview_frame.columnconfigure(0, weight=1)
treeview_scrollbar = ttk.Scrollbar(treeview_frame, orient=tk.VERTICAL)
treeview_scrollbar.grid(row=0, column=1, sticky=tk.NSEW)
self.treeview = ttk.Treeview(treeview_frame, yscrollcommand=treeview_scrollbar.set)
self.treeview.grid(row=0, column=0, sticky=tk.NSEW)
self.treeview.configure(columns=('filepath', 'titleid', 'titlename', 'status'), show='headings')
self.treeview.column('filepath', width=200, anchor=tk.W)
self.treeview.heading('filepath', text='File path')
self.treeview.column('titleid', width=70, anchor=tk.W)
self.treeview.heading('titleid', text='Title ID')
self.treeview.column('titlename', width=150, anchor=tk.W)
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)
# ---------------------------------------------------------------- #
# 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(f'custom-install {CI_VERSION} - 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.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):
l = [(self.treeview.set(k, 'titlename'), k) for k in self.treeview.get_children()]
# sort by title name
l.sort(key=lambda x: x[0].lower())
for idx, pair in enumerate(l):
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):
if not self.check_b9_loaded():
# this shouldn't happen
return False, 'Please choose boot9 first'
path = abspath(path)
if path in self.readers:
return False, 'File already in list'
try:
reader = CustomInstall.get_reader(path)
except (CIAError, CDNError, TitleMetadataError):
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:
return False, f'Exception occurred: {type(e).__name__}: {e}'
if reader.tmd.title_id.startswith('00048'):
return False, 'DSiWare is not supported'
try:
title_name = reader.contents[0].exefs.icon.get_app_title().short_desc
except:
title_name = '(No title)'
self.treeview.insert('', tk.END, text=path, iid=path,
values=(path, reader.tmd.title_id, title_name, statuses[InstallStatus.Waiting]))
self.readers[path] = reader
return True, ''
def remove_cia(self, path):
self.treeview.delete(path)
del self.readers[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.require_boot9:
b.config(state=tk.DISABLED)
for b in self.file_picker_textboxes.values():
b.config(state=tk.DISABLED)
def enable_buttons(self):
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)
def start_install(self):
sd_root = self.file_picker_textboxes['sd'].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 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
if not len(self.readers):
self.show_error('There are no titles added to install.')
return
for path in self.readers.keys():
self.update_status(path, InstallStatus.Waiting)
self.disable_buttons()
self.log('Starting install...')
if taskbar:
taskbar.SetProgressState(self.hwnd, tbl.TBPF_NORMAL)
installer = CustomInstall(movable=movable_sed,
sd=sd_root,
skip_contents=self.skip_contents_var.get() == 1,
overwrite_saves=self.overwrite_saves_var.get() == 1)
# use the treeview which has been sorted alphabetically
readers_final = []
for k in self.treeview.get_children():
filepath = self.treeview.set(k, 'filepath')
readers_final.append((self.readers[filepath], filepath))
installer.readers = readers_final
finished_percent = 0
max_percentage = 100 * len(self.readers)
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
installer.event.update_status += self.update_status
if self.skip_contents_var.get() != 1:
total_size, free_space = installer.check_size()
if total_size > free_space:
self.show_error(f'Not enough free space.\n'
f'Combined title install size: {total_size / (1024 * 1024):0.2f} MiB\n'
f'Free space: {free_space / (1024 * 1024):0.2f} MiB')
self.enable_buttons()
return
def install():
try:
result, copied_3dsx = installer.start()
if result:
result_window = InstallResults(self.parent, install_state=result, copied_3dsx=copied_3dsx)
result_window.focus()
elif result is None:
self.show_error("An error occurred when trying to run save3ds_fuse.\n"
"Either title.db doesn't exist, or save3ds_fuse couldn't be run.")
self.open_console()
except:
installer.event.on_error(exc_info())
finally:
self.enable_buttons()
Thread(target=install).start()
window = tk.Tk()
window.title(f'custom-install {CI_VERSION}')
frame = CustomInstallGUI(window)
frame.pack(fill=tk.BOTH, expand=True)
window.mainloop()

View File

@@ -5,28 +5,53 @@
# 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 os import makedirs, scandir from enum import Enum
from os.path import dirname, join from os import makedirs, rename, scandir
from os.path import dirname, join, isdir, isfile
from random import randint from random import randint
from hashlib import sha256 from hashlib import sha256
from sys import platform from locale import getpreferredencoding
from pprint import pformat
from shutil import copyfile, copy2, rmtree
import sys
from sys import platform, executable
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from traceback import format_exception
from typing import BinaryIO, TYPE_CHECKING from typing import BinaryIO, TYPE_CHECKING
import subprocess import subprocess
if TYPE_CHECKING: if TYPE_CHECKING:
from os import PathLike from os import PathLike
from typing import Union from typing import List, Union, Tuple
from events import Events from events import Events
from pyctr.crypto import CryptoEngine, Keyslot from pyctr.crypto import CryptoEngine, Keyslot, load_seeddb, get_seed
from pyctr.type.cia import CIAReader, CIASection from pyctr.type.cdn import CDNReader, CDNError
from pyctr.type.cia import CIAReader, CIAError
from pyctr.type.ncch import NCCHSection from pyctr.type.ncch import NCCHSection
from pyctr.type.tmd import TitleMetadataError
from pyctr.util import roundup from pyctr.util import roundup
if platform == 'msys':
platform = 'win32'
is_windows = platform == 'win32'
if is_windows:
from ctypes import c_wchar_p, pointer, c_ulonglong, windll
else:
from os import statvfs
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
script_dir: str = dirname(__file__) frozen = getattr(sys, 'frozen', False)
script_dir: str
if frozen:
script_dir = dirname(executable)
else:
script_dir = dirname(__file__)
# missing contents are replaced with 0xFFFFFFFF in the cmd file # missing contents are replaced with 0xFFFFFFFF in the cmd file
CMD_MISSING = b'\xff\xff\xff\xff' CMD_MISSING = b'\xff\xff\xff\xff'
@@ -50,6 +75,35 @@ 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]'):
if is_windows:
lpSectorsPerCluster = c_ulonglong(0)
lpBytesPerSector = c_ulonglong(0)
lpNumberOfFreeClusters = c_ulonglong(0)
lpTotalNumberOfClusters = c_ulonglong(0)
ret = windll.kernel32.GetDiskFreeSpaceW(c_wchar_p(path), pointer(lpSectorsPerCluster),
pointer(lpBytesPerSector),
pointer(lpNumberOfFreeClusters),
pointer(lpTotalNumberOfClusters))
if not ret:
raise WindowsError
free_blocks = lpNumberOfFreeClusters.value * lpSectorsPerCluster.value
free_bytes = free_blocks * lpBytesPerSector.value
else:
stv = statvfs(path)
free_bytes = stv.f_bavail * stv.f_frsize
return free_bytes
def load_cifinish(path: 'Union[PathLike, bytes, str]'): def load_cifinish(path: 'Union[PathLike, bytes, str]'):
try: try:
with open(path, 'rb') as f: with open(path, 'rb') as f:
@@ -130,251 +184,132 @@ def save_cifinish(path: 'Union[PathLike, bytes, str]', data: dict):
out.write(b''.join(finalize_entry_data)) out.write(b''.join(finalize_entry_data))
def get_install_size(title: 'Union[CIAReader, CDNReader]'):
sizes = [1] * 5
if title.tmd.save_size:
# one for the data directory, one for the 00000001.sav file
sizes.extend((1, title.tmd.save_size))
for record in title.content_info:
sizes.append(record.size)
# this calculates the size to put in the Title Info Entry
title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)
return title_size
class CustomInstall: class CustomInstall:
def __init__(self, *, movable, sd, cifinish_out=None, overwrite_saves=False, skip_contents=False,
cia: CIAReader boot9=None, seeddb=None):
def __init__(self, boot9, seeddb, movable, cias, sd, skip_contents=False):
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.cias = cias 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.cifinish_out = cifinish_out
self.movable = movable self.movable = movable
def copy_with_progress(self, src: BinaryIO, dst: BinaryIO, size: int, path: str): def copy_with_progress(self, src: BinaryIO, dst: BinaryIO, size: int, path: str, fire_event: bool = True):
left = size left = size
cipher = self.crypto.create_ctr_cipher(Keyslot.SD, self.crypto.sd_path_to_iv(path)) cipher = self.crypto.create_ctr_cipher(Keyslot.SD, self.crypto.sd_path_to_iv(path))
hasher = sha256()
while left > 0: while left > 0:
to_read = min(READ_SIZE, left) to_read = min(READ_SIZE, left)
data = cipher.encrypt(src.read(READ_SIZE)) data = src.read(READ_SIZE)
dst.write(data) hasher.update(data)
dst.write(cipher.encrypt(data))
left -= to_read left -= to_read
total_read = size - left total_read = size - left
self.event.update_percentage((total_read / size) * 100, total_read / 1048576, size / 1048576) if fire_event:
self.event.update_percentage((total_read / size) * 100, total_read / 1048576, size / 1048576)
return hasher.digest()
@staticmethod
def get_reader(path: 'Union[PathLike, bytes, str]'):
if isdir(path):
# try the default tmd file
reader = CDNReader(join(path, 'tmd'))
else:
try:
reader = CIAReader(path)
except CIAError:
# if there was an error with parsing the CIA header,
# the file would be tried in CDNReader next (assuming it's a tmd)
# any other error should be propagated to the caller
reader = CDNReader(path)
return reader
def prepare_titles(self, paths: 'List[PathLike]'):
readers = []
for path in paths:
self.log(f'Reading {path}')
try:
reader = self.get_reader(path)
except (CIAError, CDNError, TitleMetadataError):
self.log(f"Couldn't read {path}, likely corrupt or not a CIA or CDN title")
continue
if reader.tmd.title_id.startswith('00048'): # DSiWare
self.log(f'Skipping {reader.tmd.title_id} - DSiWare is not supported')
continue
readers.append((reader, path))
self.readers = readers
def check_size(self):
total_size = 0
for r, _ in self.readers:
total_size += get_install_size(r)
free_space = get_free_space(self.sd)
return total_size, free_space
def start(self): def start(self):
if frozen:
save3ds_fuse_path = join(script_dir, 'bin', 'save3ds_fuse')
else:
save3ds_fuse_path = join(script_dir, 'bin', platform, 'save3ds_fuse')
if is_windows:
save3ds_fuse_path += '.exe'
if not isfile(save3ds_fuse_path):
self.log("Couldn't find " + save3ds_fuse_path, 2)
return None, False
crypto = self.crypto crypto = self.crypto
# TODO: Move a lot of these into their own methods # TODO: Move a lot of these into their own methods
self.log("Finding path to install to...") self.log("Finding path to install to...")
[sd_path, id1s] = self.get_sd_path() [sd_path, id1s] = self.get_sd_path()
if len(id1s) > 1:
raise SDPathError(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, '
f'please remove extra directories')
elif len(id1s) == 0:
raise SDPathError(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}')
if self.cifinish_out:
cifinish_path = self.cifinish_out
else:
cifinish_path = join(self.sd, 'cifinish.bin')
try: try:
if len(id1s) > 1: cifinish_data = load_cifinish(cifinish_path)
raise SDPathError(f'There are multiple id1 directories for id0 {crypto.id0.hex()}, ' except InvalidCIFinishError as e:
f'please remove extra directories') self.log(f'{type(e).__qualname__}: {e}')
elif len(id1s) == 0: self.log(f'{cifinish_path} was corrupt!\n'
raise SDPathError(f'Could not find a suitable id1 directory for id0 {crypto.id0.hex()}') f'This could mean an issue with the SD card or the filesystem. Please check it for errors.\n'
except SDPathError: f'It is also possible, though less likely, to be an issue with custom-install.\n'
self.log("") f'Exiting now to prevent possible issues. If you want to try again, delete cifinish.bin from the SD card and re-run custom-install.')
return None, False
cifinish_path = join(self.sd, 'cifinish.bin')
sd_path = join(sd_path, id1s[0])
title_info_entries = {}
cifinish_data = load_cifinish(cifinish_path)
# Now loop through all provided cia files
for c in self.cias:
self.log('Reading ' + c)
cia = CIAReader(c, seeddb=self.seeddb)
self.cia = cia
tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])
try:
self.log(f'Installing {cia.contents[0].exefs.icon.get_app_title().short_desc}...')
except:
self.log('Installing...')
sizes = [1] * 5
if cia.tmd.save_size:
# one for the data directory, one for the 00000001.sav file
sizes.extend((1, cia.tmd.save_size))
for record in cia.content_info:
sizes.append(record.size)
# this calculates the size to put in the Title Info Entry
title_size = sum(roundup(x, TITLE_ALIGN_SIZE) for x in sizes)
# checks if this is dlc, which has some differences
is_dlc = tid_parts[0] == '0004008c'
# this checks if it has a manual (index 1) and is not DLC
has_manual = (not is_dlc) and (1 in cia.contents)
# this gets the extdata id from the extheader, stored in the storage info area
try:
with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
e.seek(0x200 + 0x30)
extdata_id = e.read(8)
except KeyError:
# not an executable title
extdata_id = b'\0' * 8
# cmd content id, starts with 1 for non-dlc contents
cmd_id = len(cia.content_info) if is_dlc else 1
cmd_filename = f'{cmd_id:08x}.cmd'
# get the title root where all the contents will be
title_root = join(sd_path, 'title', *tid_parts)
content_root = join(title_root, 'content')
# generate the path used for the IV
title_root_cmd = f'/title/{"/".join(tid_parts)}'
content_root_cmd = title_root_cmd + '/content'
makedirs(join(content_root, 'cmd'), exist_ok=True)
if cia.tmd.save_size:
makedirs(join(title_root, 'data'), exist_ok=True)
if is_dlc:
# create the separate directories for every 256 contents
for x in range(((len(cia.content_info) - 1) // 256) + 1):
makedirs(join(content_root, f'{x:08x}'))
# maybe this will be changed in the future
tmd_id = 0
tmd_filename = f'{tmd_id:08x}.tmd'
if not self.skip_contents:
# write the tmd
enc_path = content_root_cmd + '/' + tmd_filename
self.log(f'Writing {enc_path}...')
with cia.open_raw_section(CIASection.TitleMetadata) as s:
with open(join(content_root, tmd_filename), 'wb') as o:
self.copy_with_progress(s, o, cia.sections[CIASection.TitleMetadata].size, enc_path)
# write each content
for co in cia.content_info:
content_filename = co.id + '.app'
if is_dlc:
dir_index = format((co.cindex // 256), '08x')
enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
out_path = join(content_root, dir_index, content_filename)
else:
enc_path = content_root_cmd + '/' + content_filename
out_path = join(content_root, content_filename)
self.log(f'Writing {enc_path}...')
with cia.open_raw_section(co.cindex) as s, open(out_path, 'wb') as o:
self.copy_with_progress(s, o, co.size, enc_path)
# generate a blank save
if cia.tmd.save_size:
enc_path = title_root_cmd + '/data/00000001.sav'
out_path = join(title_root, 'data', '00000001.sav')
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
# in a new save, the first 0x20 are all 00s. the rest can be random
data = cipher.encrypt(b'\0' * 0x20)
self.log(f'Generating blank save at {enc_path}...')
with open(out_path, 'wb') as o:
o.write(data)
o.write(b'\0' * (cia.tmd.save_size - 0x20))
# generate and write cmd
enc_path = content_root_cmd + '/cmd/' + cmd_filename
out_path = join(content_root, 'cmd', cmd_filename)
self.log(f'Generating {enc_path}')
highest_index = 0
content_ids = {}
for record in cia.content_info:
highest_index = record.cindex
with cia.open_raw_section(record.cindex) as s:
s.seek(0x100)
cmac_data = s.read(0x100)
id_bytes = bytes.fromhex(record.id)[::-1]
cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes
cmac_ncch = crypto.create_cmac_object(Keyslot.CMACSDNAND)
cmac_ncch.update(sha256(cmac_data).digest())
content_ids[record.cindex] = (id_bytes, cmac_ncch.digest())
# add content IDs up to the last one
ids_by_index = [CMD_MISSING] * (highest_index + 1)
installed_ids = []
cmacs = []
for x in range(len(ids_by_index)):
try:
info = content_ids[x]
except KeyError:
# "MISSING CONTENT!"
# The 3DS does generate a cmac for missing contents, but I don't know how it works.
# It doesn't matter anyway, the title seems to be fully functional.
cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421'))
else:
ids_by_index[x] = info[0]
cmacs.append(info[1])
installed_ids.append(info[0])
installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))
final = (cmd_id.to_bytes(4, 'little')
+ len(ids_by_index).to_bytes(4, 'little')
+ len(installed_ids).to_bytes(4, 'little')
+ (1).to_bytes(4, 'little'))
cmac_cmd_header = crypto.create_cmac_object(Keyslot.CMACSDNAND)
cmac_cmd_header.update(final)
final += cmac_cmd_header.digest()
final += b''.join(ids_by_index)
final += b''.join(installed_ids)
final += b''.join(cmacs)
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(enc_path))
self.log(f'Writing {enc_path}')
with open(out_path, 'wb') as o:
o.write(cipher.encrypt(final))
# this starts building the title info entry
title_info_entry_data = [
# title size
title_size.to_bytes(8, 'little'),
# title type, seems to usually be 0x40
0x40.to_bytes(4, 'little'),
# title version
int(cia.tmd.title_version).to_bytes(2, 'little'),
# ncch version
cia.contents[0].version.to_bytes(2, 'little'),
# flags_0, only checking if there is a manual
(1 if has_manual else 0).to_bytes(4, 'little'),
# tmd content id, always starting with 0
(0).to_bytes(4, 'little'),
# cmd content id
cmd_id.to_bytes(4, 'little'),
# flags_1, only checking save data
(1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
# extdataid low
extdata_id[0:4],
# reserved
b'\0' * 4,
# flags_2, only using a common value
0x100000000.to_bytes(8, 'little'),
# product code
cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'),
# reserved
b'\0' * 0x10,
# unknown
randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
# reserved
b'\0' * 0x2c
]
title_info_entries[cia.tmd.title_id] = b''.join(title_info_entry_data)
cifinish_data[int(cia.tmd.title_id, 16)] = {'seed': (cia.contents[0].seed if cia.contents[0].flags.uses_seed else None)}
save_cifinish(cifinish_path, cifinish_data)
with TemporaryDirectory(suffix='-custom-install') as tempdir: with TemporaryDirectory(suffix='-custom-install') as tempdir:
# set up the common arguments for the two times we call save3ds_fuse # set up the common arguments for the two times we call save3ds_fuse
save3ds_fuse_common_args = [ save3ds_fuse_common_args = [
join(script_dir, 'bin', platform, 'save3ds_fuse'), save3ds_fuse_path,
'-b', crypto.b9_path, '-b', crypto.b9_path,
'-m', self.movable, '-m', self.movable,
'--sd', self.sd, '--sd', self.sd,
@@ -382,21 +317,291 @@ class CustomInstall:
tempdir tempdir
] ]
extra_kwargs = {}
if is_windows:
# hide console window
extra_kwargs['creationflags'] = 0x08000000 # CREATE_NO_WINDOW
# extract the title database to add our own entry to # extract the title database to add our own entry to
self.log('Extracting Title Database...') self.log('Extracting Title Database...')
subprocess.run(save3ds_fuse_common_args + ['-x']) out = subprocess.run(save3ds_fuse_common_args + ['-x'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding=getpreferredencoding(),
**extra_kwargs)
if out.returncode:
for l in out.stdout.split('\n'):
self.log(l)
self.log('Command line:')
for l in pformat(out.args).split('\n'):
self.log(l)
return None, False
for title_id, entry in title_info_entries.items(): sd_path = join(sd_path, id1s[0])
# write the title info entry to the temp directory
with open(join(tempdir, title_id), 'wb') as o:
o.write(entry)
# import the directory, now including our title if self.seeddb:
self.log('Importing into Title Database...') load_seeddb(self.seeddb)
subprocess.run(save3ds_fuse_common_args + ['-i'])
self.log('FINAL STEP:\nRun custom-install-finalize through homebrew launcher.') install_state = {'installed': [], 'failed': []}
self.log('This will install a ticket and seed if required.')
# Now loop through all provided cia files
for idx, info in enumerate(self.readers):
cia, path = info
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}')
makedirs(temp_title_root, exist_ok=True)
tid_parts = (cia.tmd.title_id[0:8], cia.tmd.title_id[8:16])
try:
display_title = f'{cia.contents[0].exefs.icon.get_app_title().short_desc} - {cia.tmd.title_id}'
except:
display_title = cia.tmd.title_id
self.log(f'Installing {display_title}...')
title_size = get_install_size(cia)
# checks if this is dlc, which has some differences
is_dlc = tid_parts[0] == '0004008c'
# this checks if it has a manual (index 1) and is not DLC
has_manual = (not is_dlc) and (1 in cia.contents)
# this gets the extdata id from the extheader, stored in the storage info area
try:
with cia.contents[0].open_raw_section(NCCHSection.ExtendedHeader) as e:
e.seek(0x200 + 0x30)
extdata_id = e.read(8)
except KeyError:
# not an executable title
extdata_id = b'\0' * 8
# cmd content id, starts with 1 for non-dlc contents
cmd_id = len(cia.content_info) if is_dlc else 1
cmd_filename = f'{cmd_id:08x}.cmd'
# this is where the final directory will be moved
tidhigh_root = join(sd_path, 'title', tid_parts[0])
# get the title root where all the contents will be
title_root = join(sd_path, 'title', *tid_parts)
content_root = join(title_root, 'content')
# generate the path used for the IV
title_root_cmd = f'/title/{"/".join(tid_parts)}'
content_root_cmd = title_root_cmd + '/content'
temp_content_root = join(temp_title_root, 'content')
if not self.skip_contents:
self.event.update_status(path, InstallStatus.Writing)
makedirs(join(temp_content_root, 'cmd'), exist_ok=True)
if cia.tmd.save_size:
makedirs(join(temp_title_root, 'data'), exist_ok=True)
if is_dlc:
# create the separate directories for every 256 contents
for x in range(((len(cia.content_info) - 1) // 256) + 1):
makedirs(join(temp_content_root, f'{x:08x}'), exist_ok=True)
# maybe this will be changed in the future
tmd_id = 0
tmd_filename = f'{tmd_id:08x}.tmd'
# write the tmd
tmd_enc_path = content_root_cmd + '/' + tmd_filename
self.log(f'Writing {tmd_enc_path}...')
with open(join(temp_content_root, tmd_filename), 'wb') as o:
with self.crypto.create_ctr_io(Keyslot.SD, o, self.crypto.sd_path_to_iv(tmd_enc_path)) as e:
e.write(bytes(cia.tmd))
# in case the contents are corrupted
do_continue = False
# write each content
for co in cia.content_info:
content_filename = co.id + '.app'
if is_dlc:
dir_index = format((co.cindex // 256), '08x')
content_enc_path = content_root_cmd + f'/{dir_index}/{content_filename}'
content_out_path = join(temp_content_root, dir_index, content_filename)
else:
content_enc_path = content_root_cmd + '/' + content_filename
content_out_path = join(temp_content_root, content_filename)
self.log(f'Writing {content_enc_path}...')
with cia.open_raw_section(co.cindex) as s, open(content_out_path, 'wb') as o:
result_hash = self.copy_with_progress(s, o, co.size, content_enc_path)
if result_hash != co.hash:
self.log(f'WARNING: Hash does not match for {content_enc_path}!')
install_state['failed'].append(display_title)
rename(temp_title_root, temp_title_root + '-corrupted')
do_continue = True
self.event.update_status(path, InstallStatus.Failed)
break
if do_continue:
continue
# generate a blank save
if cia.tmd.save_size:
sav_enc_path = title_root_cmd + '/data/00000001.sav'
tmp_sav_out_path = join(temp_title_root, 'data', '00000001.sav')
sav_out_path = join(title_root, 'data', '00000001.sav')
if self.overwrite_saves or not isfile(sav_out_path):
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(sav_enc_path))
# in a new save, the first 0x20 are all 00s. the rest can be random
data = cipher.encrypt(b'\0' * 0x20)
self.log(f'Generating blank save at {sav_enc_path}...')
with open(tmp_sav_out_path, 'wb') as o:
o.write(data)
o.write(b'\0' * (cia.tmd.save_size - 0x20))
else:
self.log(f'Copying original save file from {sav_enc_path}...')
copy2(sav_out_path, tmp_sav_out_path)
# generate and write cmd
cmd_enc_path = content_root_cmd + '/cmd/' + cmd_filename
cmd_out_path = join(temp_content_root, 'cmd', cmd_filename)
self.log(f'Generating {cmd_enc_path}')
highest_index = 0
content_ids = {}
for record in cia.content_info:
highest_index = record.cindex
with cia.open_raw_section(record.cindex) as s:
s.seek(0x100)
cmac_data = s.read(0x100)
id_bytes = bytes.fromhex(record.id)[::-1]
cmac_data += record.cindex.to_bytes(4, 'little') + id_bytes
cmac_ncch = crypto.create_cmac_object(Keyslot.CMACSDNAND)
cmac_ncch.update(sha256(cmac_data).digest())
content_ids[record.cindex] = (id_bytes, cmac_ncch.digest())
# add content IDs up to the last one
ids_by_index = [CMD_MISSING] * (highest_index + 1)
installed_ids = []
cmacs = []
for x in range(len(ids_by_index)):
try:
info = content_ids[x]
except KeyError:
# "MISSING CONTENT!"
# The 3DS does generate a cmac for missing contents, but I don't know how it works.
# It doesn't matter anyway, the title seems to be fully functional.
cmacs.append(bytes.fromhex('4D495353494E4720434F4E54454E5421'))
else:
ids_by_index[x] = info[0]
cmacs.append(info[1])
installed_ids.append(info[0])
installed_ids.sort(key=lambda x: int.from_bytes(x, 'little'))
final = (cmd_id.to_bytes(4, 'little')
+ len(ids_by_index).to_bytes(4, 'little')
+ len(installed_ids).to_bytes(4, 'little')
+ (1).to_bytes(4, 'little'))
cmac_cmd_header = crypto.create_cmac_object(Keyslot.CMACSDNAND)
cmac_cmd_header.update(final)
final += cmac_cmd_header.digest()
final += b''.join(ids_by_index)
final += b''.join(installed_ids)
final += b''.join(cmacs)
cipher = crypto.create_ctr_cipher(Keyslot.SD, crypto.sd_path_to_iv(cmd_enc_path))
self.log(f'Writing {cmd_enc_path}')
with open(cmd_out_path, 'wb') as o:
o.write(cipher.encrypt(final))
# this starts building the title info entry
title_info_entry_data = [
# title size
title_size.to_bytes(8, 'little'),
# title type, seems to usually be 0x40
0x40.to_bytes(4, 'little'),
# title version
int(cia.tmd.title_version).to_bytes(2, 'little'),
# ncch version
cia.contents[0].version.to_bytes(2, 'little'),
# flags_0, only checking if there is a manual
(1 if has_manual else 0).to_bytes(4, 'little'),
# tmd content id, always starting with 0
(0).to_bytes(4, 'little'),
# cmd content id
cmd_id.to_bytes(4, 'little'),
# flags_1, only checking save data
(1 if cia.tmd.save_size else 0).to_bytes(4, 'little'),
# extdataid low
extdata_id[0:4],
# reserved
b'\0' * 4,
# flags_2, only using a common value
0x100000000.to_bytes(8, 'little'),
# product code
cia.contents[0].product_code.encode('ascii').ljust(0x10, b'\0'),
# reserved
b'\0' * 0x10,
# unknown
randint(0, 0xFFFFFFFF).to_bytes(4, 'little'),
# reserved
b'\0' * 0x2c
]
self.event.update_status(path, InstallStatus.Finishing)
if isdir(title_root):
self.log(f'Removing original install at {title_root}...')
rmtree(title_root)
makedirs(tidhigh_root, exist_ok=True)
rename(temp_title_root, title_root)
cifinish_data[int(cia.tmd.title_id, 16)] = {'seed': (get_seed(cia.contents[0].program_id) if cia.contents[0].flags.uses_seed else None)}
# This is saved regardless if any titles were installed, so the file can be upgraded just in case.
save_cifinish(cifinish_path, cifinish_data)
with open(join(tempdir, cia.tmd.title_id), 'wb') as o:
o.write(b''.join(title_info_entry_data))
# import the directory, now including our title
self.log('Importing into Title Database...')
out = subprocess.run(save3ds_fuse_common_args + ['-i'],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding=getpreferredencoding(),
**extra_kwargs)
if out.returncode:
for l in out.stdout.split('\n'):
self.log(l)
self.log('Command line:')
for l in pformat(out.args).split('\n'):
self.log(l)
install_state['failed'].append(display_title)
self.event.update_status(path, InstallStatus.Failed)
else:
install_state['installed'].append(display_title)
self.event.update_status(path, InstallStatus.Done)
copied = False
if install_state['installed']:
finalize_3dsx_orig_path = join(script_dir, 'custom-install-finalize.3dsx')
hb_dir = join(self.sd, '3ds')
finalize_3dsx_path = join(hb_dir, 'custom-install-finalize.3dsx')
if isfile(finalize_3dsx_orig_path):
self.log('Copying finalize program to ' + finalize_3dsx_path)
makedirs(hb_dir, exist_ok=True)
copyfile(finalize_3dsx_orig_path, finalize_3dsx_path)
copied = True
self.log('FINAL STEP:')
self.log('Run custom-install-finalize through homebrew launcher.')
self.log('This will install a ticket and seed if required.')
if copied:
self.log('custom-install-finalize has been copied to the SD card.')
return install_state, copied
def get_sd_path(self): def get_sd_path(self):
sd_path = join(self.sd, 'Nintendo 3DS', self.crypto.id0.hex()) sd_path = join(self.sd, 'Nintendo 3DS', self.crypto.id0.hex())
@@ -441,21 +646,25 @@ class CustomInstall:
if __name__ == "__main__": if __name__ == "__main__":
parser = ArgumentParser(description='Manually install a CIA to the SD card for a Nintendo 3DS system.') parser = ArgumentParser(description='Install a CIA to the SD card for a Nintendo 3DS system.')
parser.add_argument('cia', help='CIA files', nargs='+') parser.add_argument('cia', help='CIA files', nargs='+')
parser.add_argument('-m', '--movable', help='movable.sed file', required=True) parser.add_argument('-m', '--movable', help='movable.sed file', required=True)
parser.add_argument('-b', '--boot9', help='boot9 file') parser.add_argument('-b', '--boot9', help='boot9 file')
parser.add_argument('-s', '--seeddb', help='seeddb file') parser.add_argument('-s', '--seeddb', help='seeddb file')
parser.add_argument('--sd', help='path to SD root', required=True) parser.add_argument('--sd', help='path to SD root', required=True)
parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true') parser.add_argument('--skip-contents', help="don't add contents, only add title info entry", action='store_true')
parser.add_argument('--overwrite-saves', help='overwrite existing save files', action='store_true')
parser.add_argument('--cifinish-out', help='path for cifinish.bin file, defaults to (SD root)/cifinish.bin')
print(f'custom-install {CI_VERSION} - https://github.com/ihaveamac/custom-install')
args = parser.parse_args() args = parser.parse_args()
installer = CustomInstall(boot9=args.boot9, installer = CustomInstall(boot9=args.boot9,
seeddb=args.seeddb, seeddb=args.seeddb,
cias=args.cia,
movable=args.movable, movable=args.movable,
sd=args.sd, sd=args.sd,
overwrite_saves=args.overwrite_saves,
cifinish_out=args.cifinish_out,
skip_contents=(args.skip_contents or False)) skip_contents=(args.skip_contents or False))
def log_handle(msg, end='\n'): def log_handle(msg, end='\n'):
@@ -464,7 +673,26 @@ if __name__ == "__main__":
def percent_handle(total_percent, total_read, size): def percent_handle(total_percent, total_read, size):
installer.log(f' {total_percent:>5.1f}% {total_read:>.1f} MiB / {size:.1f} MiB\r', end='') installer.log(f' {total_percent:>5.1f}% {total_read:>.1f} MiB / {size:.1f} MiB\r', end='')
def error(exc):
for line in format_exception(*exc):
for line2 in line.split('\n')[:-1]:
installer.log(line2)
installer.event.on_log_msg += log_handle installer.event.on_log_msg += log_handle
installer.event.update_percentage += percent_handle installer.event.update_percentage += percent_handle
installer.event.on_error += error
installer.start() installer.prepare_titles(args.cia)
if not args.skip_contents:
total_size, free_space = installer.check_size()
if total_size > free_space:
installer.event.on_log_msg(f'Not enough free space.\n'
f'Combined title install size: {total_size / (1024 * 1024):0.2f} MiB\n'
f'Free space: {free_space / (1024 * 1024):0.2f} MiB')
sys.exit(1)
result, copied_3dsx = installer.start()
if result is False:
# save3ds_fuse failed
installer.log('NOTE: Once save3ds_fuse is fixed, run the same command again with --skip-contents')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,7 @@
Run ci-gui to bring up the custom-install gui.
Select your SD card root, boot9, seeddb, and movable.sed files.
In some cases these will be automatically selected for you.
Add the CIA files and click "Start install".
Once it's finished, start up the homebrew launcher and run custom-install-finalize to finish the process.

View File

@@ -85,6 +85,8 @@ int load_cifinish(char* path, struct finish_db_entry_final **entries)
struct finish_db_entry_v2 v2; struct finish_db_entry_v2 v2;
struct finish_db_entry_v3 v3; struct finish_db_entry_v3 v3;
struct finish_db_entry_final *tmp;
int i; int i;
size_t read; size_t read;
@@ -114,6 +116,12 @@ int load_cifinish(char* path, struct finish_db_entry_final **entries)
} }
*entries = calloc(header.title_count, sizeof(struct finish_db_entry_final)); *entries = calloc(header.title_count, sizeof(struct finish_db_entry_final));
if (!*entries) {
printf("Couldn't allocate memory.\n");
printf("This should never happen.\n");
goto fail;
}
tmp = *entries;
if (header.version == 1) if (header.version == 1)
{ {
@@ -133,9 +141,9 @@ int load_cifinish(char* path, struct finish_db_entry_final **entries)
printf(" Is the file corrupt?\n"); printf(" Is the file corrupt?\n");
goto fail; goto fail;
} }
entries[i]->has_seed = v1.has_seed; tmp[i].has_seed = v1.has_seed;
entries[i]->title_id = v1.title_id; tmp[i].title_id = v1.title_id;
memcpy(entries[i]->seed, v1.seed, 16); memcpy(tmp[i].seed, v1.seed, 16);
} }
} else if (header.version == 2) { } else if (header.version == 2) {
for (i = 0; i < header.title_count; i++) for (i = 0; i < header.title_count; i++)
@@ -154,9 +162,9 @@ int load_cifinish(char* path, struct finish_db_entry_final **entries)
printf(" Is the file corrupt?\n"); printf(" Is the file corrupt?\n");
goto fail; goto fail;
} }
entries[i]->has_seed = v2.has_seed; tmp[i].has_seed = v2.has_seed;
entries[i]->title_id = v2.title_id; tmp[i].title_id = v2.title_id;
memcpy(entries[i]->seed, v2.seed, 16); memcpy(tmp[i].seed, v2.seed, 16);
} }
} else if (header.version == 3) { } else if (header.version == 3) {
for (i = 0; i < header.title_count; i++) for (i = 0; i < header.title_count; i++)
@@ -175,12 +183,13 @@ int load_cifinish(char* path, struct finish_db_entry_final **entries)
printf(" Is the file corrupt?\n"); printf(" Is the file corrupt?\n");
goto fail; goto fail;
} }
entries[i]->has_seed = v3.has_seed; tmp[i].has_seed = v3.has_seed;
entries[i]->title_id = v3.title_id; tmp[i].title_id = v3.title_id;
memcpy(entries[i]->seed, v3.seed, 16); memcpy(tmp[i].seed, v3.seed, 16);
} }
} }
fclose(fp);
return header.title_count; return header.title_count;
fail: fail:
@@ -209,9 +218,6 @@ void finalize_install(void)
return; return;
} }
//printf("Deleting %s...\n", CIFINISH_PATH);
//unlink(CIFINISH_PATH);
memcpy(&ticket_buf, basetik_bin, basetik_bin_size); memcpy(&ticket_buf, basetik_bin, basetik_bin_size);
for (int i = 0; i < title_count; ++i) for (int i = 0; i < title_count; ++i)
@@ -258,6 +264,9 @@ void finalize_install(void)
} }
} }
printf("Deleting %s...\n", CIFINISH_PATH);
unlink(CIFINISH_PATH);
free(entries); free(entries);
} }
@@ -267,7 +276,7 @@ int main(int argc, char* argv[])
gfxInitDefault(); gfxInitDefault();
consoleInit(GFX_TOP, NULL); consoleInit(GFX_TOP, NULL);
printf("custom-install-finalize v1.3\n"); printf("custom-install-finalize v1.5\n");
finalize_install(); finalize_install();
// print this at the end in case it gets pushed off the screen // print this at the end in case it gets pushed off the screen

435
gui.py
View File

@@ -1,435 +0,0 @@
# 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.
# A gui for custom-install.py
# By LyfeOnEdge
import os, sys, platform, subprocess, threading
import tkinter as tk
import tkinter.filedialog as tkfiledialog
import style
# BUTTON_COLOR =
# BUTTON_HIGHLIGHT_COLOR =
# BUTTON_FONT =
# Custom button
class themedFrame(tk.Frame):
def __init__(self, frame, **kw):
tk.Frame.__init__(self, frame, **kw)
if not (kw.get("background") or kw.get("bg")):
self.configure(bg=style.BACKGROUND_COLOR)
if not kw.get("borderwidth"):
self.configure(borderwidth=0)
if not kw.get("highlightthickness"):
self.configure(highlightthickness=0)
class Button(tk.Label):
"""Cross-platform button"""
def __init__(self, frame, callback, **kw):
self.callback = callback
self.background = "#aaaaaa"
self.selected = False
tk.Label.__init__(self, frame, **kw)
self.configure(anchor="center")
self.configure(background=self.background)
self.configure(highlightthickness=1)
if not "font" in kw.keys():
self.configure(font=style.BUTTON_FONT)
self.configure(highlightbackground="#999999")
self.bind('<Button-1>', self.on_click)
# Use callback when our makeshift "button" clicked
def on_click(self, event=None):
self.configure(background="#dddddd")
if not self.selected:
self.after(100, self.on_click_color_change)
if self.callback:
self.callback()
# Function to set the button's image
def setimage(self, image):
self.configure(image=image)
# Function to set the button's text
def settext(self, text):
self.configure(text=text)
def deselect(self):
self.selected = False
self.configure(background=self.background)
def on_click_color_change(self):
if not self.selected:
self.configure(background=self.background)
class PathEntry(tk.Entry):
"""Tkinter entry widget with a button to set the file path using tkinter's file dialog"""
def __init__(self, frame, dir=False, filetypes=None, *args, **kw):
self.dir = dir
self.filetypes = filetypes
container = themedFrame(frame)
self.button = Button(container, self.set_path, text="...")
self.button.place(relheight=1, relx=1, x=- style.BUTTONSIZE, width=style.BUTTONSIZE)
tk.Entry.__init__(self, container, *args, **kw)
self.text_var = tk.StringVar()
self.configure(textvariable=self.text_var)
self.configure(background=style.ENTRY_COLOR)
self.configure(foreground=style.ENTRY_FOREGROUND)
self.configure(borderwidth=0)
self.configure(highlightthickness=2)
self.configure(highlightbackground=style.BUTTON_COLOR)
super().place(relwidth=1, relheight=1, width=- style.BUTTONSIZE)
self.container = container
def clear(self):
self.text_var.set("")
def set(self, string):
self.text_var.set(string)
def get_var(self):
return self.text_var
def get(self):
return self.text_var.get()
def place(self, **kw):
self.container.place(**kw)
def set_path(self):
if not self.dir:
self.set(tkfiledialog.askopenfilename(filetypes=self.filetypes))
else:
self.set(tkfiledialog.askdirectory())
class LabeledPathEntry(PathEntry):
"""Gives the PathEntry class a label"""
def __init__(self, frame, text, *args, **kw):
self.xtainer = themedFrame(frame)
label = tk.Label(self.xtainer, text=text, background=style.BACKGROUND_COLOR, foreground=style.LABEL_COLOR)
label.place(width=label.winfo_reqwidth(), relheight=1)
PathEntry.__init__(self, self.xtainer, *args, **kw)
PathEntry.place(self, relwidth=1, relheight=1, width=- (label.winfo_reqwidth() + 5),
x=label.winfo_reqwidth() + 5)
def place(self, **kw):
self.xtainer.place(**kw)
class AutoScroll(object):
def __init__(self, master):
try:
vsb = tk.Scrollbar(master, orient='vertical', command=self.yview)
except:
pass
hsb = tk.Scrollbar(master, orient='horizontal', command=self.xview)
try:
self.configure(yscrollcommand=self._autoscroll(vsb))
except:
pass
self.configure(xscrollcommand=self._autoscroll(hsb))
self.grid(column=0, row=0, sticky='nsew')
try:
vsb.grid(column=1, row=0, sticky='ns')
except:
pass
hsb.grid(column=0, row=1, sticky='ew')
master.grid_columnconfigure(0, weight=1)
master.grid_rowconfigure(0, weight=1)
methods = tk.Pack.__dict__.keys() | tk.Grid.__dict__.keys() \
| tk.Place.__dict__.keys()
for m in methods:
if m[0] != '_' and m not in ('config', 'configure'):
setattr(self, m, getattr(master, m))
@staticmethod
def _autoscroll(sbar):
'''Hide and show scrollbar as needed.'''
def wrapped(first, last):
first, last = float(first), float(last)
if first <= 0 and last >= 1:
sbar.grid_remove()
else:
sbar.grid()
sbar.set(first, last)
return wrapped
def __str__(self):
return str(self.master)
def _create_container(func):
'''Creates a tk Frame with a given master, and use this new frame to
place the scrollbars and the widget.'''
def wrapped(cls, master, **kw):
container = themedFrame(master)
container.bind('<Enter>', lambda e: _bound_to_mousewheel(e, container))
container.bind(
'<Leave>', lambda e: _unbound_to_mousewheel(e, container))
return func(cls, container, **kw)
return wrapped
def _bound_to_mousewheel(event, widget):
child = widget.winfo_children()[0]
if platform.system() == 'Windows' or platform.system() == 'Darwin':
child.bind_all('<MouseWheel>', lambda e: _on_mousewheel(e, child))
child.bind_all('<Shift-MouseWheel>',
lambda e: _on_shiftmouse(e, child))
else:
child.bind_all('<Button-4>', lambda e: _on_mousewheel(e, child))
child.bind_all('<Button-5>', lambda e: _on_mousewheel(e, child))
child.bind_all('<Shift-Button-4>', lambda e: _on_shiftmouse(e, child))
child.bind_all('<Shift-Button-5>', lambda e: _on_shiftmouse(e, child))
def _unbound_to_mousewheel(event, widget):
if platform.system() == 'Windows' or platform.system() == 'Darwin':
widget.unbind_all('<MouseWheel>')
widget.unbind_all('<Shift-MouseWheel>')
else:
widget.unbind_all('<Button-4>')
widget.unbind_all('<Button-5>')
widget.unbind_all('<Shift-Button-4>')
widget.unbind_all('<Shift-Button-5>')
def _on_mousewheel(event, widget):
if platform.system() == 'Windows':
widget.yview_scroll(-1 * int(event.delta / 120), 'units')
elif platform.system() == 'Darwin':
widget.yview_scroll(-1 * int(event.delta), 'units')
else:
if event.num == 4:
widget.yview_scroll(-1, 'units')
elif event.num == 5:
widget.yview_scroll(1, 'units')
class ScrolledText(AutoScroll, tk.Text):
@_create_container
def __init__(self, master, **kw):
tk.Text.__init__(self, master, **kw)
AutoScroll.__init__(self, master)
# from https://stackoverflow.com/questions/3221956/how-do-i-display-tooltips-in-tkinter
class CreateToolTip(object):
'''
create a tooltip for a given widget
'''
def __init__(self, widget, text='widget info'):
self.widget = widget
self.text = text
self.widget.bind("<Enter>", self.enter)
self.widget.bind("<Leave>", self.close)
def enter(self, event=None):
x = y = 0
x, y, cx, cy = self.widget.bbox("insert")
x += self.widget.winfo_rootx()
y += self.widget.winfo_rooty() + 20
# creates a toplevel window
self.tw = tk.Toplevel(self.widget)
# Leaves only the label and removes the app window
self.tw.wm_overrideredirect(True)
self.tw.wm_geometry("+%d+%d" % (x, y))
label = tk.Label(self.tw, text=self.text, justify='left',
background='gray', foreground=style.LABEL_COLOR,
relief='solid', borderwidth=2,
font=("times", "12", "normal"),
wraplength=self.widget.winfo_width())
label.pack(ipadx=1)
def close(self, event=None):
if self.tw:
self.tw.destroy()
class threader_object:
"""an object to be declared outside of tk root so
things can be called asyncronously (you cannot start
a new thread from within a tkinter callback so you
must call it from an object that exists outside)"""
def do_async(self, func, arglist=[]):
threading.Thread(target=func, args=arglist).start()
class gui(tk.Tk):
def __init__(self, threader):
self.threader = threader
tk.Tk.__init__(self)
self.minsize(300, 400)
self.title("custom-install gui")
self.f = themedFrame(self)
self.f.place(relwidth=1, relheight=1)
outer_frame = themedFrame(self.f)
outer_frame.place(relwidth=1, relheight=1, x=+ style.STANDARD_OFFSET, width=- 2 * style.STANDARD_OFFSET,
y=+ style.STANDARD_OFFSET, height=- 2 * style.STANDARD_OFFSET)
self.sd_box = LabeledPathEntry(outer_frame, "Path to SD root -", dir=True)
self.sd_box.place(relwidth=1, height=20, x=0)
CreateToolTip(self.sd_box.xtainer, "Select the root of the sd card you wish to install the cias to.")
self.sed_box = LabeledPathEntry(outer_frame, "Path to movable.sed file -", filetypes=[('sed file', '*.sed')])
self.sed_box.place(relwidth=1, height=20, x=0, y=30)
CreateToolTip(self.sed_box.xtainer, "Select movable.sed file, this can be dumped from a 3ds")
self.boot9_box = LabeledPathEntry(outer_frame, "Path to boot9 file -", filetypes=[('boot9 file', '*.bin')])
self.boot9_box.place(relwidth=1, height=20, x=0, y=60)
CreateToolTip(self.boot9_box.xtainer, "Select the path to boot9.bin, this can be dumped from a 3ds")
# -------------------------------------------------
cia_container = themedFrame(outer_frame, borderwidth=0, highlightthickness=0)
cia_container.place(y=90, relwidth=1, height=115)
cia_label = tk.Label(cia_container, text="cia paths - ", foreground=style.LABEL_COLOR,
background=style.BACKGROUND_COLOR)
cia_label.place(relwidth=1, height=20)
self.cia_box = tk.Listbox(cia_container, highlightthickness=0, bg=style.ENTRY_COLOR,
foreground=style.ENTRY_FOREGROUND)
self.cia_box.place(relwidth=1, height=70, y=20)
CreateToolTip(cia_label,
"Select the cias you wish to install to the sd card. The `add folder` button will add all cias in the selected folder, but will not check subdirs. The `remove cia` button will remove the currently selected file from the listbox.")
add_cia_button = Button(cia_container, self.add_cia, text="add cia", font=style.monospace)
add_cia_button.place(relx=0, relwidth=0.333, height=20, y=92, width=- 6)
add_cia_folder_button = Button(cia_container, self.add_cia_folder, text="add folder", font=style.monospace)
add_cia_folder_button.place(relx=0.333, relwidth=0.333, height=20, y=92, x=+ 3, width=- 6)
remove_cia_button = Button(cia_container, self.remove_cia, text="remove cia", font=style.monospace)
remove_cia_button.place(relx=0.666, relwidth=0.333, height=20, y=92, x=+ 6, width=- 6)
# -------------------------------------------------
self.skip_contents = tk.IntVar()
skip_contents_checkbutton = tk.Checkbutton(outer_frame, text="Skip contents? (only add title info)",
variable=self.skip_contents, background=style.BACKGROUND_COLOR,
foreground=style.LABEL_COLOR, borderwidth=0, highlightthickness=0)
skip_contents_checkbutton.place(relwidth=1, y=205, height=20)
console_label = tk.Label(outer_frame, text="Console:", background="black", foreground="white",
font=style.boldmonospace, borderwidth=0, highlightthickness=0)
console_label.place(relwidth=1, height=20, y=230)
self.console = ScrolledText(outer_frame, background="black", foreground="white", highlightthickness=0)
self.console.place(relwidth=1, relheight=1, y=250, height=- 272)
run_button = Button(outer_frame, self.run, text="run", font=style.boldmonospace)
run_button.place(relwidth=1, rely=1, y=- 22)
def run(self):
argstring = ""
self.output_to_console("-----------------------\nStarting...\n")
boot9 = self.boot9_box.get()
if not boot9:
self.output_to_console(
"Warning - boot9 not selected, if it's not set externally you may run into problems.\n")
argstring += f"-b {boot9} "
sed = self.sed_box.get()
if not sed:
self.output_to_console("Failed to run - No movable.sed selected.\n")
return
argstring += f"-m {sed} "
sd = self.sd_box.get().strip()
if not sd:
self.output_to_console("Failed to run - SD path not selected.\n")
return
argstring += f"--sd {sd} "
cias = []
for i in range(0, self.cia_box.size()):
cias.append(self.cia_box.get(i).strip())
for cia in cias:
argstring += f" {cia}"
if self.skip_contents.get():
argstring += "--skip-contents "
print(f"Running custom-install.py with args {args}\n")
self.threader.do_async(execute_script, [argstring, self.output_to_console])
def output_to_console(self, outstring):
self.console.insert('end', outstring)
self.console.see('end')
def add_cia(self):
cia_to_add = tkfiledialog.askopenfilename(filetypes=[('cia file', '*.cia')])
if cia_to_add:
self.cia_box.insert('end', cia_to_add)
def add_cia_folder(self):
cia_dir_to_add = tkfiledialog.askdirectory()
if cia_dir_to_add:
cias_to_add = [f for f in os.listdir(cia_dir_to_add) if
(os.path.isfile(os.path.join(cia_dir_to_add, f)) and f.endswith(".cia"))]
if cias_to_add:
for cia_to_add in cias_to_add:
self.cia_box.insert('end', cia_to_add)
def remove_cia(self):
index = self.cia_box.curselection()
if index:
self.cia_box.delete(index)
if self.cia_box.size():
self.cia_box.select_clear(0, 'end')
if self.cia_box.size() > 1:
try:
self.cia_box.select_set(index)
except:
pass
else:
self.cia_box.select_set(0)
def execute_script(argstring, printer):
"""Wrapper function to pipe install script output to a printer"""
try:
args = [sys.executable, '-u', os.path.join(os.path.dirname(__file__), "custominstall.py")]
for arg in argstring.split():
args.append(arg.strip())
p = subprocess.Popen(args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
with p.stdout:
for line in iter(p.stdout.readline, b''):
printer(line)
p.wait()
except Exception as e:
printer(f"Error while executing script with args - {argstring} | Exception - {e}\n")
t = threader_object()
window = gui(t)
window.mainloop()

11
make-standalone.bat Normal file
View File

@@ -0,0 +1,11 @@
mkdir build
mkdir dist
cxfreeze ci-gui.py --target-dir=build\custom-install-standalone --base-name=Win32GUI
mkdir build\custom-install-standalone\bin
copy TaskbarLib.tlb build\custom-install-standalone
copy bin\win32\save3ds_fuse.exe build\custom-install-standalone\bin
copy bin\README build\custom-install-standalone\bin
copy custom-install-finalize.3dsx build\custom-install-standalone
copy extras\windows-quickstart.txt build\custom-install-standalone
copy LICENSE.md build\custom-install-standalone
python -m zipfile -c dist\custom-install-standalone.zip build\custom-install-standalone

2
requirements-win32.txt Normal file
View File

@@ -0,0 +1,2 @@
-r requirements.txt
comtypes==1.1.7

View File

@@ -1,3 +1,2 @@
pycryptodomex<=3.9.4 events==0.4
events==0.3 pyctr==0.4.5
pyctr==0.1.0

View File

@@ -1,18 +0,0 @@
STANDARD_OFFSET = 10 #Offset to place everything
BUTTONSIZE = 30
monospace = ("Monospace",10)
boldmonospace = ("Monospace",10,"bold")
BUTTON_FONT = monospace
BACKGROUND_COLOR = "#20232a"
BUTTON_COLOR = "#aaaaaa"
ENTRY_COLOR = "#373940"
ENTRY_FOREGROUND = "black"
LABEL_COLOR = "#61dafb"

View File

@@ -0,0 +1,13 @@
# This is meant to be double-clicked from File Explorer.
# This doesn't import pip as a module in case the way it's executed changes, which it has in the past.
# Instead we call it like we would in the command line.
from subprocess import run
from os.path import dirname, join
from sys import executable
root_dir = dirname(__file__)
run([executable, '-m', 'pip', 'install', '--user', '-r', join(root_dir, 'requirements-win32.txt')])
input('Press enter to close')