mirror of
https://github.com/ihaveamac/ninfs.git
synced 2025-06-19 17:35:47 -04:00
1259 lines
52 KiB
Python
Executable File
1259 lines
52 KiB
Python
Executable File
# This file is a part of fuse-3ds.
|
|
#
|
|
# Copyright (c) 2017-2018 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.
|
|
|
|
# not very good with gui development...
|
|
# don't read this file, it sucks
|
|
print('Importing dependencies...')
|
|
|
|
import json
|
|
import webbrowser
|
|
from configparser import ConfigParser
|
|
from contextlib import suppress
|
|
from glob import iglob
|
|
from hashlib import sha256
|
|
from os import environ, kill, makedirs, rmdir
|
|
from os.path import abspath, dirname, expanduser, getsize, isfile, isdir, ismount, join as pjoin
|
|
from shutil import get_terminal_size
|
|
from ssl import PROTOCOL_TLSv1_2, SSLContext
|
|
from subprocess import Popen, check_call, CalledProcessError
|
|
from sys import argv, executable, exit, platform, version_info, maxsize, stderr
|
|
from time import sleep
|
|
from traceback import print_exc
|
|
from typing import TYPE_CHECKING
|
|
from urllib.request import urlopen
|
|
|
|
try:
|
|
from appJar import gui
|
|
from appJar.appjar import ItemLookupError
|
|
except ImportError as e:
|
|
print_exc()
|
|
print(file=stderr)
|
|
if 'tk' in e.args[0].lower():
|
|
exit('Could not import tkinter, please install python3-tk (or equivalent for your distribution).')
|
|
else:
|
|
exit('Could not import appJar. If you installed via pip, make sure you installed with the gui.\n'
|
|
'Read the README at the GitHub repository.')
|
|
# this code will never be reached
|
|
gui = ItemLookupError = None
|
|
|
|
from pkg_resources import parse_version
|
|
|
|
from __init__ import __version__ as version
|
|
from fmt_detect import detect_format
|
|
from pyctr.util import config_dirs
|
|
|
|
if TYPE_CHECKING:
|
|
from http.client import HTTPResponse
|
|
# noinspection PyProtectedMember
|
|
from pkg_resources._vendor.packaging.version import Version
|
|
from typing import Any, Dict, List
|
|
|
|
windows = platform == 'win32' # only for native windows, not cygwin
|
|
macos = platform == 'darwin'
|
|
|
|
ITEM = 'item'
|
|
EASTWEST = 'ew'
|
|
PV = 'previous'
|
|
LABEL1 = 'label1'
|
|
LABEL2 = 'label2'
|
|
LABEL3 = 'label3'
|
|
MOUNTPOINT = 'mountpoint'
|
|
|
|
MOUNT = 'Mount'
|
|
UNMOUNT = 'Unmount'
|
|
DIRECTORY = 'Directory'
|
|
FILE = 'File'
|
|
OK = 'OK'
|
|
|
|
DRAGFILE = 'Drag a file here or browse...'
|
|
DRAGDIR = 'Drag a directory here or browse...'
|
|
BROWSE = 'Browse...'
|
|
ALLOW_OTHER = 'Allow access by other users (-o allow_other)'
|
|
ALLOW_ROOT = 'Allow access by root (-o allow_root)'
|
|
ALLOW_NONE = "Don't allow other users"
|
|
|
|
b9_hashes = {
|
|
'2f88744feed717856386400a44bba4b9ca62e76a32c715d4f309c399bf28166f': 'boot9',
|
|
'7331f7edece3dd33f2ab4bd0b3a5d607229fd19212c10b734cedcaf78c1a7b98': 'boot9_prot',
|
|
}
|
|
|
|
b9_paths: 'List[str]' = []
|
|
for p in config_dirs:
|
|
b9_paths.append(pjoin(p, 'boot9.bin'))
|
|
b9_paths.append(pjoin(p, 'boot9_prot.bin'))
|
|
|
|
with suppress(KeyError):
|
|
b9_paths.insert(0, environ['BOOT9_PATH'])
|
|
|
|
seeddb_paths: 'List[str]' = [pjoin(x, 'seeddb.bin') for x in config_dirs]
|
|
with suppress(KeyError):
|
|
seeddb_paths.insert(0, environ['SEEDDB_PATH'])
|
|
|
|
home = expanduser('~')
|
|
|
|
# dir to copy to if chosen to copy boot9/seeddb in gui
|
|
if windows:
|
|
target_dir: str = pjoin(environ.get('APPDATA'), '3ds')
|
|
config_dir: str = pjoin(environ.get('APPDATA'), 'fuse-3ds')
|
|
elif macos:
|
|
target_dir: str = pjoin(home, 'Library', 'Application Support', '3ds')
|
|
config_dir: str = pjoin(home, 'Library', 'Application Support', 'fuse-3ds')
|
|
else:
|
|
target_dir: str = pjoin(home, '.3ds')
|
|
config_root = environ.get('XDG_CONFIG_HOME')
|
|
if not config_root:
|
|
# check other paths in XDG_CONFIG_DIRS to see if fuse-3ds already exists in one of them
|
|
config_roots: str = environ.get('XDG_CONFIG_DIRS')
|
|
if not config_roots:
|
|
config_roots = '/etc/xdg'
|
|
config_paths = config_roots.split(':')
|
|
for p in config_paths:
|
|
d = pjoin(p, 'fuse-3ds')
|
|
if isdir(d):
|
|
config_root = p
|
|
break
|
|
# check again to see if it was set
|
|
if not config_root:
|
|
config_root = pjoin(home, '.config')
|
|
config_dir = pjoin(config_root, 'fuse-3ds')
|
|
|
|
makedirs(config_dir, exist_ok=True)
|
|
update_config = ConfigParser()
|
|
|
|
configs = {'update': update_config}
|
|
|
|
|
|
def init_config(kind: str, defaults):
|
|
config_path = pjoin(config_dir, kind + '.cfg')
|
|
if not configs[kind].read(config_path):
|
|
print('Creating new', kind, 'config...')
|
|
configs[kind].update(defaults)
|
|
write_config(kind)
|
|
else:
|
|
print('Loaded', kind, 'config.')
|
|
|
|
|
|
def write_config(kind: str, section=None, option=None, value=None):
|
|
config_path = pjoin(config_dir, kind + '.cfg')
|
|
if section:
|
|
configs[kind][section][option] = str(value)
|
|
try:
|
|
with open(config_path, 'w', encoding='utf-8') as o:
|
|
configs[kind].write(o)
|
|
print('Wrote', kind, 'config to', config_path)
|
|
return True
|
|
except Exception:
|
|
print_exc()
|
|
print()
|
|
print('Failed to write', kind, 'config to', config_path)
|
|
return False
|
|
|
|
|
|
init_config('update', {'update': {'check_updates_online': True, 'ignored_update': 'v0.0'}})
|
|
|
|
|
|
for p in b9_paths:
|
|
if isfile(p):
|
|
b9_found = p
|
|
break
|
|
else:
|
|
b9_found = ''
|
|
|
|
for p in seeddb_paths:
|
|
if isfile(p):
|
|
seeddb_found = p
|
|
break
|
|
else:
|
|
seeddb_found = ''
|
|
|
|
# types
|
|
CCI = 'CTR Cart Image (".3ds", ".cci")'
|
|
CDN = 'CDN contents ("cetk", "tmd", and contents)'
|
|
CIA = 'CTR Importable Archive (".cia")'
|
|
EXEFS = 'Executable Filesystem (".exefs", "exefs.bin")'
|
|
NAND = 'NAND backup ("nand.bin")'
|
|
NANDDSI = 'Nintendo DSi NAND backup ("nand_dsi.bin")'
|
|
NCCH = 'NCCH (".cxi", ".cfa", ".ncch", ".app")'
|
|
ROMFS = 'Read-only Filesystem (".romfs", "romfs.bin")'
|
|
SD = 'SD Card Contents ("Nintendo 3DS" from SD)'
|
|
SRL = 'Nintendo DS ROM image (".nds", ".srl")'
|
|
THREEDSX = '3DSX Homebrew (".3dsx")'
|
|
TITLEDIR = 'Titles directory ("title" from NAND or SD)'
|
|
|
|
mount_types = {CCI: 'cci', CDN: 'cdn', CIA: 'cia', EXEFS: 'exefs', NAND: 'nand', NANDDSI: 'nanddsi', NCCH: 'ncch',
|
|
ROMFS: 'romfs', SD: 'sd', SRL: 'srl', THREEDSX: 'threedsx', TITLEDIR: 'titledir'}
|
|
|
|
mount_types_rv: 'Dict[str, str]' = {y: x for x, y in mount_types.items()}
|
|
|
|
ctr_types = (CCI, CDN, CIA, EXEFS, NAND, NCCH, ROMFS, SD, THREEDSX, TITLEDIR)
|
|
twl_types = (NANDDSI, SRL)
|
|
types_list = ctr_types + twl_types
|
|
|
|
types_requiring_b9 = {CCI, CDN, CIA, NAND, NCCH, SD, TITLEDIR}
|
|
|
|
if windows:
|
|
from ctypes import windll
|
|
from platform import win32_ver
|
|
from signal import CTRL_BREAK_EVENT
|
|
from string import ascii_uppercase
|
|
from subprocess import CREATE_NEW_PROCESS_GROUP
|
|
from sys import stdout
|
|
# noinspection PyUnresolvedReferences
|
|
from winreg import OpenKey, QueryValueEx, HKEY_LOCAL_MACHINE
|
|
|
|
from reg_shell import add_reg, del_reg, uac_enabled
|
|
|
|
# unlikely, but this causes issues
|
|
if stdout is None: # happens if pythonw is used on windows
|
|
# this one uses MessageBoxW directly because the gui hasn't been made yet.
|
|
res = windll.user32.MessageBoxW(None, (
|
|
'This is being run with the wrong Python executable.\n'
|
|
'This should be installed as a module, then run using the py launcher on Python 3.6.1 or later.\n\n'
|
|
'Click OK to open the fuse-3ds repository on GitHub:\n'
|
|
'https://github.com/ihaveamac/fuse-3ds'),
|
|
'fuse-3ds', 0x00000010 | 0x00000001)
|
|
if res == 1:
|
|
webbrowser.open('https://github.com/ihaveamac/fuse-3ds')
|
|
exit(1)
|
|
|
|
|
|
def get_unused_drives():
|
|
# https://stackoverflow.com/questions/827371/is-there-a-way-to-list-all-the-available-drive-letters-in-python
|
|
drives: List[str] = []
|
|
bitmask = windll.kernel32.GetLogicalDrives()
|
|
for letter in ascii_uppercase:
|
|
if not bitmask & 1:
|
|
drives.append(letter)
|
|
bitmask >>= 1
|
|
|
|
return drives
|
|
|
|
|
|
def update_drives():
|
|
o = get_unused_drives()
|
|
app.changeOptionBox(MOUNTPOINT, (x + ':' for x in o))
|
|
app.setOptionBox(MOUNTPOINT, o[-1] + ':')
|
|
elif macos:
|
|
from platform import mac_ver
|
|
|
|
_used_pyinstaller = False
|
|
_ndw_resp = False
|
|
process: Popen = None
|
|
curr_mountpoint: str = None
|
|
|
|
pyver = f'{version_info[0]}.{version_info[1]}.{version_info[2]}'
|
|
if version_info[3] != 'final':
|
|
pyver += f'{version_info[3][0]}{version_info[4]}'
|
|
pybits = 64 if maxsize > 0xFFFFFFFF else 32
|
|
|
|
app = gui('fuse-3ds v' + version, showIcon=False, handleArgs=False)
|
|
if windows:
|
|
app.setIcon(pjoin(dirname(__file__), 'data', 'windows.ico'))
|
|
|
|
|
|
def run_mount(module_type: str, item: str, mountpoint: str, extra_args: list = ()):
|
|
global process, curr_mountpoint
|
|
if process is None or process.poll() is not None:
|
|
environ['BOOT9_PATH'] = b9_found
|
|
environ['SEEDDB_PATH'] = seeddb_found
|
|
args = [executable]
|
|
if not _used_pyinstaller:
|
|
args.append(dirname(__file__))
|
|
args.extend((module_type, '-f', item, mountpoint))
|
|
args.extend(extra_args)
|
|
curr_mountpoint = mountpoint
|
|
x, _ = get_terminal_size()
|
|
print('-' * (x - 1))
|
|
print('Running:', args)
|
|
opts = {}
|
|
if windows:
|
|
opts['creationflags'] = CREATE_NEW_PROCESS_GROUP
|
|
process = Popen(args, **opts)
|
|
|
|
# check if the mount exists, or if the process exited before it
|
|
check = isdir if windows else ismount
|
|
while not check(mountpoint):
|
|
sleep(1)
|
|
if process.poll() is not None:
|
|
show_mounterror()
|
|
app.queueFunction(app.enableButton, MOUNT)
|
|
return
|
|
|
|
app.queueFunction(app.enableButton, UNMOUNT)
|
|
|
|
if windows:
|
|
with suppress(CalledProcessError):
|
|
# not using startfile since i've been getting fatal errors (PyEval_RestoreThread) on windows
|
|
# for some reason
|
|
# also this error always appears when calling explorer, so i'm ignoring it
|
|
check_call(['explorer', mountpoint.replace('/', '\\')])
|
|
elif macos:
|
|
try:
|
|
check_call(['/usr/bin/open', '-a', 'Finder', mountpoint])
|
|
except CalledProcessError:
|
|
print(f'Failed to open Finder on {mountpoint}')
|
|
print_exc()
|
|
else: # probably linux
|
|
try:
|
|
check_call(['xdg-open', mountpoint])
|
|
except CalledProcessError:
|
|
print(f'Failed to open the file manager on {mountpoint}')
|
|
print_exc()
|
|
|
|
if process.wait() != 0:
|
|
# just in case there are leftover mounts
|
|
try:
|
|
stop_mount()
|
|
except CalledProcessError:
|
|
print_exc()
|
|
show_exiterror(process.returncode)
|
|
|
|
app.queueFunction(app.disableButton, UNMOUNT)
|
|
app.queueFunction(app.enableButton, MOUNT)
|
|
|
|
|
|
def stop_mount():
|
|
if process is not None and process.poll() is None:
|
|
print('Stopping')
|
|
if windows:
|
|
kill(process.pid, CTRL_BREAK_EVENT)
|
|
else:
|
|
# this is cheating...
|
|
if macos:
|
|
check_call(['diskutil', 'unmount', curr_mountpoint])
|
|
else:
|
|
# assuming linux or bsd, which have fusermount
|
|
check_call(['fusermount', '-u', curr_mountpoint])
|
|
|
|
|
|
def press(button: str):
|
|
if button == MOUNT:
|
|
extra_args = []
|
|
mount_type = app.getOptionBox('TYPE')
|
|
app.disableButton(MOUNT)
|
|
item = app.getEntry(mount_type + ITEM)
|
|
if not item:
|
|
show_noitemerror()
|
|
app.enableButton(MOUNT)
|
|
return
|
|
|
|
if windows:
|
|
if app.getRadioButton('mountpoint-choice') == 'Drive letter':
|
|
if mount_type in {NAND, NANDDSI}:
|
|
res = app.okBox(
|
|
'fuse-3ds Warning',
|
|
'You chose drive letter when using the NAND mount.\n'
|
|
'\n'
|
|
'Using a directory mount over a drive letter for NAND is highly '
|
|
'recommended because some tools like OSFMount will not be '
|
|
'able to read from files in a mount using a drive letter.\n'
|
|
'\n'
|
|
'Are you sure you want to continue?'
|
|
)
|
|
if not res:
|
|
app.enableButton(MOUNT)
|
|
return
|
|
mountpoint = app.getOptionBox(MOUNTPOINT)
|
|
else:
|
|
mountpoint = app.getEntry(MOUNTPOINT)
|
|
if not mountpoint:
|
|
show_nomperror()
|
|
app.enableButton(MOUNT)
|
|
return
|
|
try:
|
|
# winfsp won't work on an existing directory
|
|
# so we try to use rmdir, which will delete it, only if it's empty
|
|
rmdir(mountpoint)
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as e:
|
|
# noinspection PyUnresolvedReferences
|
|
if isinstance(e, OSError) and e.winerror == 145: # "The directory is not empty"
|
|
show_mounterror_dir_win()
|
|
else:
|
|
print_exc()
|
|
show_mounterror()
|
|
app.enableButton(MOUNT)
|
|
return
|
|
else:
|
|
mountpoint = app.getEntry(MOUNTPOINT)
|
|
if not mountpoint:
|
|
show_nomperror()
|
|
app.enableButton(MOUNT)
|
|
return
|
|
|
|
if mount_type == CDN:
|
|
key = app.getEntry(CDN + 'key')
|
|
if key:
|
|
try:
|
|
_res = bytes.fromhex(key)
|
|
except ValueError:
|
|
app.warningBox('fuse-3ds Error', 'The given titlekey was not a valid hexstring.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
if len(_res) != 16:
|
|
app.warningBox('fuse-3ds Error', 'The given titlekey must be 32 characters.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
extra_args.extend(('--dec-key', key))
|
|
elif mount_type == NAND:
|
|
otp = app.getEntry(NAND + 'otp')
|
|
aw = app.getCheckBox(NAND + 'aw')
|
|
if otp:
|
|
extra_args.extend(('--otp', otp))
|
|
if not aw:
|
|
extra_args.append('-r')
|
|
elif mount_type == NANDDSI:
|
|
consoleid = app.getEntry(NANDDSI + 'consoleid')
|
|
aw = app.getCheckBox(NANDDSI + 'aw')
|
|
if consoleid:
|
|
if consoleid.lower().startswith('fw'):
|
|
app.warningBox('fuse-3ds Error', 'A real Console ID does not start with FW. '
|
|
'It is a 16-character hexstring.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
try:
|
|
_res = bytes.fromhex(consoleid)
|
|
except ValueError:
|
|
app.warningBox('fuse-3ds Error', 'The given Console ID was not a valid hexstring.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
if len(_res) != 8:
|
|
app.warningBox('fuse-3ds Error', 'The given Console ID must be 16 characters.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
extra_args.extend(('--console-id', consoleid))
|
|
if not aw:
|
|
extra_args.append('-r')
|
|
elif mount_type == SD:
|
|
movable = app.getEntry(SD + 'movable')
|
|
aw = app.getCheckBox(SD + 'aw')
|
|
if not movable:
|
|
app.warningBox('fuse-3ds Error', 'A movable.sed is required.')
|
|
app.enableButton(MOUNT)
|
|
return
|
|
extra_args.extend(('--movable', movable))
|
|
if not aw:
|
|
extra_args.append('-r')
|
|
elif mount_type == TITLEDIR:
|
|
decompress = app.getCheckBox(TITLEDIR + 'decompress')
|
|
mount_all = app.getCheckBox(TITLEDIR + 'mountall')
|
|
if decompress:
|
|
extra_args.append('--decompress-code')
|
|
if mount_all:
|
|
extra_args.append('--mount-all')
|
|
elif mount_type == EXEFS:
|
|
decompress = app.getCheckBox(EXEFS + 'decompress')
|
|
if decompress:
|
|
extra_args.append('--decompress-code')
|
|
|
|
if app.getCheckBox('debug'):
|
|
extra_args.extend(('--do', app.getEntry('debug')))
|
|
|
|
if not windows:
|
|
allow_user = app.getRadioButton('allowuser')
|
|
if allow_user == ALLOW_ROOT:
|
|
extra_args.extend(('-o', 'allow_root'))
|
|
elif allow_user == ALLOW_OTHER:
|
|
extra_args.extend(('-o', 'allow_other'))
|
|
|
|
if mount_type in types_requiring_b9:
|
|
use_dev_keys = app.getCheckBox('devkeys')
|
|
if use_dev_keys:
|
|
extra_args.append('--dev')
|
|
|
|
app.thread(run_mount, mount_types[mount_type], item, mountpoint, extra_args)
|
|
|
|
elif button == UNMOUNT:
|
|
app.disableButton(UNMOUNT)
|
|
# noinspection PyBroadException
|
|
try:
|
|
stop_mount()
|
|
app.enableButton(MOUNT)
|
|
except Exception:
|
|
print_exc()
|
|
app.showSubWindow('unmounterror')
|
|
app.enableButton(UNMOUNT)
|
|
elif button == 'Settings & Help':
|
|
show_extras()
|
|
|
|
|
|
def kill_process(_):
|
|
process.kill()
|
|
app.hideSubWindow('unmounterror')
|
|
app.enableButton(MOUNT)
|
|
app.disableButton(UNMOUNT)
|
|
|
|
|
|
current_type = 'default'
|
|
|
|
|
|
def check_mount_button(mount_type: str):
|
|
if mount_type not in types_list:
|
|
return
|
|
if not b9_found and mount_type in types_requiring_b9:
|
|
app.disableButton(MOUNT)
|
|
else:
|
|
if process is None or process.poll() is not None:
|
|
app.enableButton(MOUNT)
|
|
|
|
|
|
def change_type(*_):
|
|
global current_type
|
|
mount_type: str = app.getOptionBox('TYPE')
|
|
try:
|
|
app.openLabelFrame('Mount settings')
|
|
except ItemLookupError:
|
|
app.setSticky(EASTWEST)
|
|
app.startLabelFrame('Mount settings', row=1, colspan=3)
|
|
app.setSticky(EASTWEST)
|
|
app.showLabelFrame('Mount settings')
|
|
|
|
app.hideFrame(current_type)
|
|
|
|
try:
|
|
app.showFrame(mount_type)
|
|
except ItemLookupError:
|
|
if mount_type == CCI:
|
|
with app.frame(CCI, row=1, colspan=3):
|
|
app.addLabel(CCI + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(CCI + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(CCI + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == CDN:
|
|
with app.frame(CDN, row=1, colspan=3):
|
|
app.addLabel(CDN + LABEL1, DIRECTORY, row=0, column=0)
|
|
app.addDirectoryEntry(CDN + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(CDN + ITEM, DRAGDIR)
|
|
|
|
app.addLabel(CDN + LABEL2, 'Decrypted Titlekey*', row=3, column=0)
|
|
app.addEntry(CDN + 'key', row=3, column=1, colspan=2)
|
|
|
|
app.setEntryDefault(CDN + 'key', 'Insert a decrypted titlekey')
|
|
app.addLabel(CDN + LABEL3, '*Not required if title has a cetk.', row=4, colspan=3)
|
|
|
|
elif mount_type == CIA:
|
|
with app.frame(CIA, row=1, colspan=3):
|
|
app.addLabel(CIA + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(CIA + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(CIA + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == EXEFS:
|
|
with app.frame(EXEFS, row=1, colspan=3):
|
|
app.addLabel(EXEFS + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(EXEFS + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(EXEFS + ITEM, DRAGFILE)
|
|
|
|
app.addLabel(EXEFS + LABEL3, 'Options', row=3, column=0)
|
|
app.addNamedCheckBox('Decompress .code', EXEFS + 'decompress', row=3, column=1, colspan=1)
|
|
|
|
elif mount_type == NAND:
|
|
with app.frame(NAND, row=1, colspan=3):
|
|
app.addLabel(NAND + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(NAND + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(NAND + ITEM, DRAGFILE)
|
|
|
|
app.addLabel(NAND + LABEL2, 'OTP file*', row=2, column=0)
|
|
app.addFileEntry(NAND + 'otp', row=2, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(NAND + 'otp', DRAGFILE)
|
|
|
|
app.addLabel(NAND + 'label4', '*Not required if backup has essential.exefs from GodMode9.', row=3,
|
|
colspan=3)
|
|
|
|
app.addLabel(NAND + 'label5', 'Options', row=4, column=0)
|
|
app.addNamedCheckBox('Allow writing', NAND + 'aw', row=4, column=1, colspan=1)
|
|
|
|
if has_dnd:
|
|
app.setEntryDropTarget(NAND + 'otp', make_dnd_entry_check(NAND + 'otp'))
|
|
|
|
elif mount_type == NANDDSI:
|
|
with app.frame(NANDDSI, row=1, colspan=3):
|
|
app.addLabel(NANDDSI + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(NANDDSI + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(NANDDSI + ITEM, DRAGFILE)
|
|
|
|
app.addLabel(NANDDSI + LABEL2, 'Console ID*', row=2, column=0)
|
|
app.addEntry(NANDDSI + 'consoleid', row=2, column=1, colspan=2)
|
|
app.setEntryDefault(NANDDSI + 'consoleid', 'If required, input Console ID as hexstring')
|
|
|
|
app.addLabel(NANDDSI + LABEL3, '*Not required if backup has nocash footer with ConsoleID/CID.', row=3,
|
|
colspan=3)
|
|
|
|
app.addLabel(NANDDSI + 'label4', 'Options', row=5, column=0)
|
|
app.addNamedCheckBox('Allow writing', NANDDSI + 'aw', row=5, column=1, colspan=1)
|
|
|
|
elif mount_type == NCCH:
|
|
with app.frame(NCCH, row=1, colspan=3):
|
|
app.addLabel(NCCH + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(NCCH + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(NCCH + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == ROMFS:
|
|
with app.frame(ROMFS, row=1, colspan=3):
|
|
app.addLabel(ROMFS + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(ROMFS + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(ROMFS + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == SD:
|
|
with app.frame(SD, row=1, colspan=3):
|
|
app.addLabel(SD + LABEL1, DIRECTORY, row=0, column=0)
|
|
app.addDirectoryEntry(SD + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(SD + ITEM, DRAGDIR)
|
|
|
|
app.addLabel(SD + LABEL2, 'movable.sed', row=2, column=0)
|
|
app.addFileEntry(SD + 'movable', row=2, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(SD + 'movable', DRAGFILE)
|
|
|
|
app.addLabel(SD + LABEL3, 'Options', row=3, column=0)
|
|
app.addNamedCheckBox('Allow writing', SD + 'aw', row=3, column=1, colspan=1)
|
|
|
|
if has_dnd:
|
|
app.setEntryDropTarget(SD + 'movable', make_dnd_entry_check(SD + 'movable'))
|
|
|
|
elif mount_type == THREEDSX:
|
|
with app.frame(THREEDSX, row=1, colspan=3):
|
|
app.addLabel(THREEDSX + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(THREEDSX + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(THREEDSX + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == SRL:
|
|
with app.frame(SRL, row=1, colspan=3):
|
|
app.addLabel(SRL + LABEL1, FILE, row=0, column=0)
|
|
app.addFileEntry(SRL + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(SRL + ITEM, DRAGFILE)
|
|
|
|
elif mount_type == TITLEDIR:
|
|
with app.frame(TITLEDIR, row=1, colspan=3):
|
|
app.addLabel(TITLEDIR + LABEL1, DIRECTORY, row=0, column=0)
|
|
app.addDirectoryEntry(TITLEDIR + ITEM, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(TITLEDIR + ITEM, DRAGFILE)
|
|
|
|
app.addLabel(TITLEDIR + LABEL3, 'Options', row=3, column=0)
|
|
app.addNamedCheckBox('Decompress .code (slow!)', TITLEDIR + 'decompress', row=3, column=1, colspan=1)
|
|
app.addNamedCheckBox('Mount all contents', TITLEDIR + 'mountall', row=3, column=2, colspan=1)
|
|
|
|
if has_dnd:
|
|
app.setEntryDropTarget(mount_type + ITEM, make_dnd_entry_check(mount_type + ITEM))
|
|
|
|
finally:
|
|
app.stopLabelFrame()
|
|
current_type = mount_type
|
|
|
|
try:
|
|
app.showLabelFrame('Mount point')
|
|
except ItemLookupError:
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Mount point', row=2, colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
with app.frame('mountpoint-root', colspan=3):
|
|
if windows:
|
|
def rb_change(_):
|
|
if app.getRadioButton('mountpoint-choice') == 'Drive letter':
|
|
app.hideFrame('mountpoint-dir')
|
|
app.showFrame('mountpoint-drive')
|
|
else:
|
|
app.hideFrame('mountpoint-drive')
|
|
app.showFrame('mountpoint-dir')
|
|
|
|
app.addLabel('mountpoint-choice-label', 'Mount type', row=0)
|
|
app.addRadioButton('mountpoint-choice', 'Drive letter', row=0, column=1)
|
|
app.addRadioButton('mountpoint-choice', 'Directory', row=0, column=2)
|
|
|
|
app.setRadioButtonChangeFunction('mountpoint-choice', rb_change)
|
|
with app.frame('mountpoint-drive', row=1, colspan=3):
|
|
app.addLabel('mountlabel1', 'Drive letter', row=0, column=0)
|
|
# putting "WWWW" to avoid a warning
|
|
app.addOptionBox(MOUNTPOINT, ['WWWW'], row=0, column=1, colspan=2)
|
|
|
|
with app.frame('mountpoint-dir', row=1, colspan=3):
|
|
app.addLabel('mountlabel2', 'Mount point', row=0, column=0)
|
|
app.addDirectoryEntry(MOUNTPOINT, row=0, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.hideFrame('mountpoint-dir')
|
|
# noinspection PyUnboundLocalVariable
|
|
update_drives()
|
|
else:
|
|
app.addLabel('mountlabel', 'Mount point', row=2, column=0)
|
|
app.addDirectoryEntry(MOUNTPOINT, row=2, column=1, colspan=2).theButton.config(text=BROWSE)
|
|
app.setEntryDefault(MOUNTPOINT, DRAGDIR)
|
|
|
|
def toggle_advanced_opts(_):
|
|
if app.getCheckBox('advopt'):
|
|
app.showLabelFrame('Advanced options')
|
|
else:
|
|
app.hideLabelFrame('Advanced options')
|
|
|
|
def choose_debug_location(_):
|
|
path: str = app.saveBox(fileName='fuse3ds.log', dirName=pjoin(home, 'Desktop'),
|
|
fileTypes=(),
|
|
fileExt='.log')
|
|
if path:
|
|
app.setEntry('debug', path)
|
|
|
|
app.addNamedCheckBox('Show advanced options', 'advopt', column=1, colspan=2)
|
|
app.setCheckBoxChangeFunction('advopt', toggle_advanced_opts)
|
|
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Advanced options', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
|
|
app.addLabel('devkeys-label', 'Keys')
|
|
app.addNamedCheckBox('Use developer-unit keys', 'devkeys', row=PV, column=1, colspan=2)
|
|
|
|
if not windows:
|
|
app.addLabel('allowuser-label', 'External user access')
|
|
app.addRadioButton('allowuser', ALLOW_NONE, row=PV, column=1, colspan=2)
|
|
app.addRadioButton('allowuser', ALLOW_ROOT, column=1, colspan=2)
|
|
app.addRadioButton('allowuser', ALLOW_OTHER, column=1, colspan=2)
|
|
if not macos:
|
|
app.addMessage('allowuser-notice',
|
|
'This may require extra permissions, such as adding the user to the '
|
|
'fuse group, or editing /etc/fuse.conf.', column=1, colspan=2)
|
|
app.setMessageAlign('allowuser-notice', 'left')
|
|
app.setMessageAspect('allowuser-notice', 500)
|
|
|
|
app.addLabel('debug-label1', 'Debug output')
|
|
app.addNamedCheckBox('Enable debug output', 'debug', row=PV, column=1, colspan=2)
|
|
app.addLabel('debug-label2', 'Debug log file')
|
|
app.addNamedButton('Choose log location...', 'debug', choose_debug_location, row=PV, column=1,
|
|
colspan=2)
|
|
app.addEntry('debug', column=1, colspan=2)
|
|
app.setEntryDefault('debug', 'Choose where to save above...')
|
|
|
|
app.hideLabelFrame('Advanced options')
|
|
|
|
app.addButtons([MOUNT, UNMOUNT], press, colspan=3)
|
|
app.disableButton(UNMOUNT)
|
|
|
|
if has_dnd:
|
|
app.setEntryDropTarget(MOUNTPOINT, make_dnd_entry_check(MOUNTPOINT))
|
|
|
|
check_mount_button(mount_type)
|
|
|
|
try:
|
|
if mount_type in types_requiring_b9:
|
|
app.showFrame('FOOTER')
|
|
else:
|
|
app.hideFrame('FOOTER')
|
|
except ItemLookupError:
|
|
pass
|
|
|
|
if mount_type in {NAND, NANDDSI} and windows:
|
|
app.setRadioButton('mountpoint-choice', 'Directory')
|
|
|
|
|
|
def make_dnd_entry_check(entry_name: str):
|
|
def handle(data: str):
|
|
if data.startswith('{'):
|
|
data = data[1:-1]
|
|
app.setEntry(entry_name, data)
|
|
|
|
return handle
|
|
|
|
|
|
print('Setting up GUI...')
|
|
|
|
with app.frame('loading', row=1, colspan=3):
|
|
app.addLabel('l-label', 'Getting ready...', colspan=3)
|
|
|
|
|
|
def show_unknowntype(path: str):
|
|
app.warningBox('fuse-3ds Error',
|
|
"The type of the given file couldn't be detected."
|
|
"If you know it is a compatibile file, choose the "
|
|
"correct type and file an issue on GitHub if it works.\n\n"
|
|
+ path)
|
|
|
|
|
|
def detect_type(fn: str):
|
|
if fn.startswith('{'):
|
|
fn = fn[1:-1]
|
|
# noinspection PyBroadException
|
|
try:
|
|
with open(fn, 'rb') as f:
|
|
mt = detect_format(f.read(0x200))
|
|
if mt is not None:
|
|
mount_type = mount_types_rv[mt]
|
|
else:
|
|
show_unknowntype(fn)
|
|
return
|
|
except (IsADirectoryError, PermissionError): # PermissionError sometimes occurs on Windows
|
|
if isfile(pjoin(fn, 'tmd')):
|
|
mount_type = CDN
|
|
else:
|
|
try:
|
|
next(iglob(pjoin(fn, '[0-9a-f]' * 32)))
|
|
except StopIteration:
|
|
# no entries
|
|
mount_type = TITLEDIR
|
|
else:
|
|
# at least one entry
|
|
mount_type = SD
|
|
except Exception:
|
|
print('Failed to get type of', fn)
|
|
print_exc()
|
|
return
|
|
|
|
app.setOptionBox('TYPE', mount_type)
|
|
app.setEntry(mount_type + ITEM, fn)
|
|
app.showFrame(mount_type)
|
|
|
|
|
|
app.setSticky('new')
|
|
# this is here to force the width to be set
|
|
app.addOptionBox('TYPE', ('- Choose a type or drag a file/directory here -',), row=0, colspan=2)
|
|
app.setOptionBoxChangeFunction('TYPE', change_type)
|
|
app.addButton('Settings & Help', press, row=0, column=2)
|
|
try:
|
|
app.setOptionBoxDropTarget('TYPE', detect_type)
|
|
has_dnd = True
|
|
except Exception as e:
|
|
print('Warning: Failed to enable Drag & Drop, will not be used.')
|
|
print_exc()
|
|
has_dnd = False
|
|
|
|
app.changeOptionBox('TYPE', (f'- Choose a type{" or drag a file/directory here" if has_dnd else ""} -',
|
|
'- Nintendo 3DS -', *ctr_types,
|
|
'- Nintendo DS / DSi -', *twl_types))
|
|
|
|
with app.frame('default', row=1, colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Getting started', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
if has_dnd:
|
|
app.addLabel('d-label1', 'To get started, drag the file to the box above.', colspan=2)
|
|
app.addLabel('d-label2', 'You can also click it to manually choose a type.', colspan=2)
|
|
else:
|
|
app.addLabel('d-label1', 'To get started, choose a type to mount above.', colspan=2)
|
|
app.addHorizontalSeparator(colspan=2)
|
|
app.addLabel('d-label3', 'Need help?')
|
|
app.addWebLink('View a tutorial here!', 'https://gbatemp.net/threads/499994/', column=1, row=PV)
|
|
app.setLabelAlign('d-label3', 'right')
|
|
app.setLinkAlign('View a tutorial here!', 'left')
|
|
app.hideFrame('default') # to be shown later
|
|
|
|
|
|
def select_seeddb(sw):
|
|
global seeddb_found
|
|
path: str = app.openBox(title='Choose SeedDB', fileTypes=())
|
|
if path:
|
|
if getsize(path) % 0x10:
|
|
app.warningBox('fuse-3ds Error',
|
|
'The size for the selected SeedDB is not aligned to 0x10.')
|
|
# shouldn't be calling this directly but i seem to have encounered a bug with parent=
|
|
# noinspection PyProtectedMember
|
|
app._bringToFront(sw)
|
|
return
|
|
with open(path, 'rb') as f:
|
|
data = f.read(0x1FFFF0)
|
|
target = pjoin(target_dir, 'seeddb.bin')
|
|
makedirs(target_dir, exist_ok=True)
|
|
with open(target, 'wb') as o:
|
|
o.write(data)
|
|
app.infoBox('fuse-3ds', 'SeedDB was copied to:\n\n' + target)
|
|
seeddb_found = target
|
|
with suppress(ItemLookupError):
|
|
app.hideLabel('no-seeddb')
|
|
app.hideButton('fix-seeddb')
|
|
if b9_found and seeddb_found:
|
|
app.hideFrame('FOOTER')
|
|
app.hideSubWindow('no-seeddb')
|
|
|
|
|
|
if not b9_found or not seeddb_found:
|
|
with app.frame('FOOTER', row=3, colspan=3):
|
|
if not b9_found:
|
|
app.addLabel('no-b9', 'boot9 was not found. Click for more details.', colspan=2)
|
|
app.setLabelBg('no-b9', '#ff9999')
|
|
app.addNamedButton('Fix boot9', 'fix-b9', lambda _: app.showSubWindow('no-b9'), row=PV, column=2)
|
|
|
|
|
|
def select_b9(sw):
|
|
global b9_found
|
|
path: str = app.openBox(title='Choose boot9', fileTypes=(), parent='no-b9')
|
|
if path:
|
|
if getsize(path) not in {0x8000, 0x10000}:
|
|
app.warningBox('fuse-3ds Error',
|
|
'The size for the selected boot9 match is not 0x8000 or 0x10000.')
|
|
# shouldn't be calling this directly but i seem to have encounered a bug with parent=
|
|
# noinspection PyProtectedMember
|
|
app._bringToFront(sw)
|
|
return
|
|
with open(path, 'rb') as f:
|
|
data = f.read(0x10000)
|
|
try:
|
|
fn = b9_hashes[sha256(data).hexdigest()]
|
|
except KeyError:
|
|
app.warningBox('fuse-3ds Error', 'boot9 hash did not match. Please re-dump boot9.')
|
|
# noinspection PyProtectedMember
|
|
app._bringToFront(sw)
|
|
return
|
|
target = pjoin(target_dir, fn + '.bin')
|
|
makedirs(target_dir, exist_ok=True)
|
|
with open(target, 'wb') as o:
|
|
o.write(data)
|
|
app.infoBox('fuse-3ds', 'boot9 was copied to:\n\n' + target)
|
|
b9_found = target
|
|
app.hideLabel('no-b9')
|
|
app.hideButton('fix-b9')
|
|
if b9_found and seeddb_found:
|
|
app.hideFrame('FOOTER')
|
|
check_mount_button(app.getOptionBox('TYPE'))
|
|
app.hideSubWindow('no-b9')
|
|
|
|
|
|
with app.subWindow('no-b9', 'fuse-3ds Error', modal=True) as sw:
|
|
app.addLabel('boot9 was not found. It is needed for encryption.\n'
|
|
'Mount types that use encryption have been disabled.\n'
|
|
'\n'
|
|
'Choose this to automatically set up boot9.')
|
|
app.addNamedButton('Select boot9 to copy...', 'no-b9-select', lambda _: select_b9(sw))
|
|
app.setSticky(EASTWEST)
|
|
app.addHorizontalSeparator()
|
|
app.addLabel('The following paths were checked for a boot9 dump:')
|
|
app.addListBox('b9-paths', b9_paths)
|
|
app.setListBoxRows('b9-paths', len(b9_paths))
|
|
app.setSticky('')
|
|
app.addNamedButton(OK, 'no-b9-ok', lambda _: app.hideSubWindow('no-b9'))
|
|
app.setResizable(False)
|
|
|
|
if not seeddb_found:
|
|
app.addLabel('no-seeddb', 'SeedDB was not found. Click for more details.', colspan=2)
|
|
app.setLabelBg('no-seeddb', '#ffff99')
|
|
app.addNamedButton('Fix SeedDB', 'fix-seeddb', lambda _: app.showSubWindow('no-seeddb'), row=PV,
|
|
column=2)
|
|
|
|
with app.subWindow('no-seeddb', 'fuse-3ds Error', modal=True) as sw:
|
|
app.addLabel('SeedDB was not found. It is needed for encryption\n'
|
|
'of newer digital titles.\n'
|
|
'\n'
|
|
'Choose this to automatically set up SeedDB.')
|
|
app.addNamedButton('Select SeedDB to copy...', 'no-seeddb-select', lambda _: select_seeddb(sw))
|
|
app.setSticky(EASTWEST)
|
|
app.addHorizontalSeparator()
|
|
app.addLabel('The following paths were checked for SeedDB:')
|
|
app.addListBox('seeddb-paths', seeddb_paths)
|
|
app.setListBoxRows('seeddb-paths', len(seeddb_paths))
|
|
app.setSticky('')
|
|
app.addNamedButton(OK, 'no-seeddb-ok', lambda _: app.hideSubWindow('no-seeddb'))
|
|
app.setResizable(False)
|
|
app.hideFrame('FOOTER')
|
|
|
|
if windows:
|
|
app.setFont(10)
|
|
elif macos:
|
|
app.setFont(14)
|
|
app.setResizable(False)
|
|
|
|
|
|
# failed to mount subwindow
|
|
def show_mounterror():
|
|
app.warningBox('fuse-3ds Error', 'Failed to mount. Please check the output.')
|
|
|
|
|
|
# exited with error subwindow
|
|
def show_exiterror(errcode: int):
|
|
app.warningBox('fuse-3ds Error',
|
|
f'The mount process exited with an error code ({errcode}). Please check the output.')
|
|
|
|
|
|
# failed to mount to directory subwindow
|
|
if windows:
|
|
def show_mounterror_dir_win():
|
|
app.warningBox('fuse-3ds Error',
|
|
'Failed to mount to the given mount point.\n'
|
|
'Please make sure the directory is empty or does not exist.')
|
|
|
|
|
|
def show_extras():
|
|
try:
|
|
app.showSubWindow('extras')
|
|
except ItemLookupError:
|
|
with app.subWindow('extras', 'fuse-3ds Extras', modal=True, blocking=False) as sw:
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Settings'):
|
|
def checkbox_update(name: str):
|
|
if name == 'update-check':
|
|
option = app.getCheckBox('update-check')
|
|
if not write_config('update', 'update', 'check_updates_online', option):
|
|
app.errorBox('fuse-3ds Error', 'Failed to write to config. Is the path read-only? '
|
|
'Check the output for more details.')
|
|
|
|
app.addNamedCheckBox('Check for updates at launch', 'update-check')
|
|
app.setCheckBox('update-check', update_config.getboolean('update', 'check_updates_online'))
|
|
app.setCheckBoxChangeFunction('update-check', checkbox_update)
|
|
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Update SeedDB', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
app.addLabel('updateseeddb-label', 'Update SeedDB to a newer database.', colspan=2)
|
|
app.addNamedButton('Update', 'updateseeddb-btn', lambda _: select_seeddb(sw), row=PV, column=2)
|
|
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Tutorial', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
app.addLabel('tutorial-label', 'View a tutorial on GBAtemp.', colspan=2)
|
|
app.addNamedButton('Open', 'tutorial-btn',
|
|
lambda _: webbrowser.open('https://gbatemp.net/threads/499994/'),
|
|
row=PV, column=2)
|
|
|
|
def _show_ctxmenu_window():
|
|
app.showSubWindow('ctxmenu-window')
|
|
app.hideSubWindow('extras')
|
|
|
|
if windows:
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('Context Menu', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
app.addLabel('ctxmenu-label', 'Add an entry to the right-click menu.', colspan=2)
|
|
app.addNamedButton('Add', 'ctxmenu-btn', _show_ctxmenu_window, row=PV,
|
|
column=2)
|
|
|
|
def _show_about():
|
|
app.showSubWindow('about')
|
|
app.hideSubWindow('extras')
|
|
|
|
app.setSticky(EASTWEST)
|
|
with app.labelFrame('About', colspan=3):
|
|
app.setSticky(EASTWEST)
|
|
app.addLabel('about-label', 'Open the about dialog.')
|
|
app.addNamedButton('Open', 'about-btn', _show_about, row=PV, column=2)
|
|
|
|
app.setResizable(False)
|
|
|
|
app.setSticky(EASTWEST)
|
|
with app.subWindow('about', 'fuse-3ds', modal=True, blocking=False):
|
|
app.setSticky(EASTWEST)
|
|
if windows:
|
|
_ver = win32_ver()
|
|
os_ver = 'Windows ' + _ver[0]
|
|
if _ver[0] == '10':
|
|
k = OpenKey(HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows NT\CurrentVersion')
|
|
try:
|
|
r: str = QueryValueEx(k, 'ReleaseId')[0]
|
|
except FileNotFoundError:
|
|
r = '1507' # assuming RTM, since this key only exists in 1511 and up
|
|
os_ver += ', version ' + r
|
|
else:
|
|
if _ver[2] != 'SP0':
|
|
os_ver += ' ' + _ver[2]
|
|
elif macos:
|
|
os_ver = 'macOS ' + mac_ver()[0]
|
|
else:
|
|
os_ver = ''
|
|
if os_ver:
|
|
os_ver += '\n'
|
|
app.addMessage('about-msg', f'fuse-3ds v{version}\n'
|
|
f'Running on Python {pyver} {pybits}-bit\n'
|
|
f'{os_ver}'
|
|
f'\n'
|
|
f'fuse-3ds is released under the MIT license.', colspan=4)
|
|
app.setMessageAspect('about-msg', 500)
|
|
app.addWebLink('View fuse-3ds on GitHub', 'https://github.com/ihaveamac/fuse-3ds', colspan=4)
|
|
app.addLabel('These libraries are used in the project:', colspan=4)
|
|
app.addWebLink('appJar', 'https://github.com/jarvisteach/appJar')
|
|
app.addWebLink('PyCryptodome', 'https://github.com/Legrandin/pycryptodome', row=PV, column=1)
|
|
app.addWebLink('fusepy', 'https://github.com/fusepy/fusepy', row=PV, column=2)
|
|
app.addWebLink('PyInstaller', 'https://github.com/pyinstaller/pyinstaller', row=PV, column=3)
|
|
app.setResizable(False)
|
|
|
|
if windows:
|
|
def add_entry(button: str):
|
|
app.hideSubWindow('ctxmenu-window')
|
|
if button == 'Add entry':
|
|
add_reg(_used_pyinstaller)
|
|
elif button == 'Remove entry':
|
|
del_reg()
|
|
|
|
with app.subWindow('ctxmenu-window', 'fuse-3ds', modal=True):
|
|
msg = (
|
|
'A new entry can be added to the Windows context menu when you right-click on a file, providing an '
|
|
'easy way to mount various files in Windows Explorer using fuse-3ds.\n'
|
|
'\n'
|
|
'This will modify the registry to add it.')
|
|
if _used_pyinstaller:
|
|
msg += ' If you move or rename the EXE, you will need to re-add the entry.'
|
|
app.addMessage('extras-ctxmenu-label', msg)
|
|
app.setMessageAspect('extras-ctxmenu-label', 300)
|
|
app.addButtons(['Add entry', 'Remove entry', 'Cancel'], add_entry, colspan=3)
|
|
app.setResizable(False)
|
|
|
|
app.showSubWindow('extras')
|
|
|
|
|
|
# file/directory not set error
|
|
def show_noitemerror():
|
|
app.infoBox('fuse-3ds Error', 'Select a file or directory to mount.')
|
|
|
|
|
|
# mountpoint not set error
|
|
def show_nomperror():
|
|
app.infoBox('fuse-3ds Error', 'Select an empty directory to be the mount point.')
|
|
|
|
|
|
# failed to unmount subwindow
|
|
with app.subWindow('unmounterror', 'fuse-3ds Error', modal=True, blocking=False):
|
|
def unmount_ok(_):
|
|
app.hideSubWindow('unmounterror')
|
|
app.enableButton(UNMOUNT)
|
|
|
|
|
|
app.addLabel('unmounterror-label', 'Failed to unmount. Please check the output.\n\n'
|
|
'You can kill the process if it is not responding. '
|
|
'This should be used as a last resort. '
|
|
'The process should be unmounted normally.', colspan=2)
|
|
app.addNamedButton(OK, 'unmounterror-ok', unmount_ok)
|
|
app.addNamedButton('Kill process', 'unmounterror-kill', kill_process, row=PV, column=1)
|
|
app.setResizable(False)
|
|
|
|
|
|
# maybe get the file via an argument to main? instead of taking it from argv
|
|
# it probably doesn't matter much
|
|
def main(_pyi=False, _allow_admin=False):
|
|
global _used_pyinstaller
|
|
_used_pyinstaller = _pyi
|
|
print('Doing final setup...')
|
|
try:
|
|
# attempt importing all the fusepy stuff used in the mount scripts
|
|
# if it fails, libfuse probably couldn't be found
|
|
import fuse
|
|
except EnvironmentError:
|
|
# TODO: probably check if this was really "Unable to find libfuse" (this is aliased to OSError)
|
|
if windows:
|
|
res = app.yesNoBox('fuse-3ds',
|
|
'Failed to import fusepy. WinFsp needs to be installed.\n\n'
|
|
'Would you like to open the WinFsp download page?\n'
|
|
'http://www.secfs.net/winfsp/download/')
|
|
if res:
|
|
webbrowser.open('http://www.secfs.net/winfsp/download/')
|
|
elif macos:
|
|
res = app.yesNoBox('fuse-3ds',
|
|
'Failed to import fusepy. FUSE for macOS needs to be installed.\n\n'
|
|
'Would you like to open the FUSE for macOS download page?\n'
|
|
'https://osxfuse.github.io')
|
|
if res:
|
|
webbrowser.open('https://osxfuse.github.io')
|
|
else:
|
|
print("Failed to load fusepy. libfuse probably couldn't be found.")
|
|
return 1
|
|
|
|
if windows and not _allow_admin:
|
|
isadmin: int = windll.shell32.IsUserAnAdmin()
|
|
if isadmin and uac_enabled():
|
|
app.warningBox('fuse-3ds',
|
|
'This should not be run as administrator.\n'
|
|
'The mount point may not be accessible by your account normally, '
|
|
'only by the administrator.\n\n'
|
|
'If you are having issues with administrative tools not seeing files, '
|
|
'choose a directory as a mount point instead of a drive letter.')
|
|
exit(1)
|
|
|
|
def show_update(name: str, ver: str, info: str, url: str):
|
|
def update_press(button: str):
|
|
if button == 'Open release page':
|
|
webbrowser.open(url)
|
|
app.queueFunction(app.stop)
|
|
elif button == 'Ignore this update':
|
|
if not write_config('update', 'update', 'ignored_update', ver):
|
|
app.errorBox('fuse-3ds Error', 'Failed to write to config. Is the path read-only? '
|
|
'Check the output for more details.')
|
|
app.destroySubWindow('update')
|
|
|
|
with app.subWindow('update', 'fuse-3ds Update', modal=True):
|
|
app.addLabel('update-label1', f'A new version of fuse-3ds is available. You have v{version}.')
|
|
app.addButtons(['Open release page', 'Ignore this update', 'Close'], update_press)
|
|
with app.labelFrame(name):
|
|
app.addMessage('update-info', info)
|
|
app.setMessageAlign('update-info', 'left')
|
|
app.setMessageAspect('update-info', 300)
|
|
app.setResizable(False)
|
|
|
|
app.showSubWindow('update')
|
|
|
|
if update_config.getboolean('update', 'check_updates_online'):
|
|
def update_check():
|
|
# this will check for the latest non-prerelease, once there is one.
|
|
# noinspection PyBroadException
|
|
try:
|
|
print(f'UPDATE: Checking for updates... (Currently running v{version})')
|
|
ctx = SSLContext(PROTOCOL_TLSv1_2)
|
|
release_url = 'https://api.github.com/repos/ihaveamac/fuse-3ds/releases'
|
|
current_ver: 'Version' = parse_version(version)
|
|
if not current_ver.is_prerelease:
|
|
release_url += '/latest'
|
|
with urlopen(release_url, context=ctx) as u:
|
|
u: HTTPResponse
|
|
res: List[Dict[str, Any]] = json.loads(u.read().decode('utf-8'))
|
|
latest_rel = res[0] if current_ver.is_prerelease else res
|
|
latest_ver: str = latest_rel['tag_name']
|
|
if parse_version(latest_ver) > current_ver:
|
|
name: str = latest_rel['name']
|
|
url: str = latest_rel['html_url']
|
|
info_all: str = latest_rel['body']
|
|
info = info_all[:info_all.find('------')].strip().replace('\r\n', '\n')
|
|
|
|
if latest_ver == update_config['update']['ignored_update']:
|
|
print(f'UPDATE: Update to {latest_ver} is available but ignored.')
|
|
else:
|
|
print(f'UPDATE: Update to {latest_ver} is available.')
|
|
app.queueFunction(show_update, name, latest_ver, info, url)
|
|
else:
|
|
print(f'UPDATE: No new version. (Latest is {latest_ver})')
|
|
|
|
except Exception:
|
|
print('UPDATE: Failed to check for update')
|
|
print_exc()
|
|
|
|
app.thread(update_check)
|
|
else:
|
|
print('UPDATE: Online update check disabled.')
|
|
|
|
to_use = 'default'
|
|
|
|
if len(argv) > 1:
|
|
fn: str = abspath(argv[1])
|
|
# noinspection PyBroadException
|
|
try:
|
|
with open(fn, 'rb') as f:
|
|
mt = detect_format(f.read(0x200))
|
|
if mt is not None:
|
|
mount_type = mount_types_rv[mt]
|
|
to_use = mount_type
|
|
file_to_use = fn
|
|
else:
|
|
show_unknowntype(fn)
|
|
except (IsADirectoryError, PermissionError): # PermissionError sometimes occurs on Windows
|
|
if isfile(pjoin(fn, 'tmd')):
|
|
mount_type = CDN
|
|
else:
|
|
try:
|
|
next(iglob(pjoin(fn, '[0-9a-f]' * 32)))
|
|
except StopIteration:
|
|
# no entries
|
|
mount_type = TITLEDIR
|
|
else:
|
|
# at least one entry
|
|
mount_type = SD
|
|
to_use = mount_type
|
|
file_to_use = fn
|
|
except Exception:
|
|
print('Failed to get type of', fn)
|
|
print_exc()
|
|
|
|
# kinda lame way to prevent a resize bug
|
|
def sh():
|
|
if to_use == 'default':
|
|
app.queueFunction(app.showFrame, 'default')
|
|
else:
|
|
app.queueFunction(detect_type, file_to_use)
|
|
app.queueFunction(app.hideFrame, 'loading')
|
|
|
|
app.thread(sh)
|
|
|
|
print('Starting the GUI!')
|
|
app.go()
|
|
stop_mount()
|
|
return 0
|