├── .github └── FUNDING.yml ├── .gitignore ├── BUILDING.md ├── DEVELOPMENT.md ├── LICENSE.md ├── README.md ├── default.nix ├── flake.lock ├── flake.nix ├── ninfs ├── __init__.py ├── __main__.py ├── _frozen_main.py ├── fmt_detect.py ├── gui │ ├── __init__.py │ ├── about.py │ ├── confighandler.py │ ├── data │ │ ├── 1024x1024.png │ │ ├── 128x128.png │ │ ├── 16x16.png │ │ ├── 32x32.png │ │ ├── 64x64.png │ │ ├── licenses │ │ │ ├── haccrypto.md │ │ │ ├── ninfs.md │ │ │ ├── pycryptodome.rst │ │ │ ├── pyctr │ │ │ └── winfsp.txt │ │ └── windows.ico │ ├── opendir.py │ ├── optionsframes.py │ ├── osver.py │ ├── outputviewer.py │ ├── settings.py │ ├── setupwizard │ │ ├── __init__.py │ │ ├── base.py │ │ ├── cci.py │ │ ├── cdn.py │ │ ├── cia.py │ │ ├── exefs.py │ │ ├── nandbb.py │ │ ├── nandctr.py │ │ ├── nandhac.py │ │ ├── nandtwl.py │ │ ├── ncch.py │ │ ├── romfs.py │ │ ├── sd.py │ │ ├── sdtitle.py │ │ ├── srl.py │ │ └── threedsx.py │ ├── supportfiles.py │ ├── updatecheck.py │ └── wizardcontainer.py ├── main.py ├── mount │ ├── __init__.py │ ├── _common.py │ ├── cci.py │ ├── cdn.py │ ├── cia.py │ ├── exefs.py │ ├── nandbb.py │ ├── nandctr.py │ ├── nandhac.py │ ├── nandtwl.py │ ├── ncch.py │ ├── romfs.py │ ├── sd.py │ ├── sdtitle.py │ ├── srl.py │ └── threedsx.py ├── mountinfo.py ├── reg_shell.py └── winpathmodify.py ├── nix ├── haccrypto.nix └── mfusepy.nix ├── package.nix ├── pyproject.toml ├── requirements.txt ├── resources ├── InternetAccessPolicy.plist ├── MacGettingStarted.pages ├── MacGettingStarted.pdf ├── cia-mount-mac.png ├── mac-entitlements.plist └── ninfs.1 ├── scripts ├── make-dmg-mac.sh ├── make-exe-win.bat ├── make-icons.sh ├── make-inst-win.bat └── make-zip-win.bat ├── setup-cxfreeze.py ├── setup.py ├── standalone.spec ├── templates └── readonly.py └── wininstbuild ├── README.md ├── installer.nsi ├── licenses.txt └── winfsp-2.0.23075.msi /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: ihaveahax 2 | patreon: ihaveahax 3 | custom: ["https://paypal.me/ihaveamac"] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | .idea/ 4 | venv*/ 5 | *.bin 6 | *.egg-info/ 7 | __pycache__/ 8 | .DS_Store 9 | *.so 10 | *.pyd 11 | # dll and dylib are not here, since libcrypto needs to be included 12 | Thumbs.db 13 | result 14 | result-* 15 | -------------------------------------------------------------------------------- /BUILDING.md: -------------------------------------------------------------------------------- 1 | This is still being worked on (as of January 29, 2024). 2 | 3 | # Windows 4 | 5 | ## Standalone build 6 | This expects Python 3.8 32-bit to be installed. 7 | 8 | Install the dependencies: 9 | ```batch 10 | py -3.8-32 -m pip install --user --upgrade "cx-Freeze>=6.15,<6.16" -r requirements.txt 11 | ``` 12 | 13 | Build the exe: 14 | ```batch 15 | scripts\make-exe-win.bat 16 | ``` 17 | 18 | Build the standalone zip: 19 | ```batch 20 | scripts\make-zip-win.bat 21 | ``` 22 | 23 | Build the NSIS installer (by default this depends on it being installed to `C:\Program Files (x86)\NSIS`): 24 | ``` 25 | scripts\make-inst-win.bat 26 | ``` 27 | 28 | ## Wheel and source dist build 29 | * `py -3 setup.py bdist_wheel` - build multi-platform py3 wheel 30 | * `py -3 setup.py sdist` - build source distribution 31 | 32 | # macOS 33 | This needs Python built with universal2 to produce a build with a working GUI. A universal2 build will be made. 34 | 35 | Set up a venv, activate it, and install the requirements: 36 | ```sh 37 | python3.11 -m venv venv311 38 | source venv311/bin/activate 39 | pip install --upgrade pyinstaller certifi -r requirements.txt 40 | ``` 41 | 42 | Build the icns: 43 | ```sh 44 | ./scripts/make-icons.sh 45 | ``` 46 | 47 | Build the app: 48 | ```sh 49 | pyinstaller standalone.spec 50 | ``` 51 | 52 | ## Distributing for release 53 | Mostly for my reference, but in case you want to try and reproduce a build. If you are not signing and notarizing the app (which requires giving Apple $99 for a yearly developer program membership), you can just build the dmg and ignore the rest. 54 | 55 | ## DMG only 56 | ```sh 57 | ./scripts/make-dmg-mac.sh 58 | ``` 59 | 60 | ## Sign and notarize 61 | Most of this was taken from this gist: https://gist.github.com/txoof/0636835d3cc65245c6288b2374799c43 62 | 63 | Sign the app: 64 | ```sh 65 | codesign \ 66 | --force \ 67 | --deep \ 68 | --options runtime \ 69 | --entitlements ./resources/mac-entitlements.plist \ 70 | --sign "" \ 71 | --timestamp \ 72 | ./dist/ninfs.app 73 | ``` 74 | 75 | `--timestamp` is not necessarily required (and could probably be removed for test builds), but it is if you are notarizing the app. 76 | 77 | Build the dmg: 78 | ```sh 79 | ./scripts/make-dmg-mac.sh 80 | ``` 81 | 82 | Upload for notarization: 83 | ```sh 84 | xcrun notarytool submit ./dist/ninfs--macos.dmg --keychain-profile "AC_PASSWORD" --wait 85 | ``` 86 | 87 | Staple the ticket to the dmg: 88 | ```sh 89 | xcrun stapler staple ./dist/ninfs--macos.dmg 90 | ``` 91 | 92 | todo: 93 | * Use pyinstaller for Windows in the same spec file (trying to do this with nsis might suck) 94 | 95 | ## Wheel and source dist build 96 | * `python3 setup.py bdist_wheel` - build multi-platform py3 wheel 97 | * `python3 setup.py sdist` - build source distribution 98 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | This is a work in progress, please do not hesitate to contact me if there are any questions. 2 | 3 | ## Before you continue... 4 | The focus of ninfs is to allow easy extraction of data from file types relevant to Nintendo consoles that would be useful mounting as a virtual filesystem. 5 | 6 | ### Goals 7 | - File types that are standard on Nintendo consoles 8 | - File types that are standard across games used on Nintendo consoles (e.g. darc) 9 | - Standard/generic file types that are used often on Nintendo consoles (e.g. FAT32) 10 | 11 | ### Non-goals 12 | - File types for other devices (e.g. Xbox or PlayStation), though ninfs can be used as a base for a separate project 13 | - Creation of files (e.g. game or save file containers, NAND images) 14 | - File types that would not be useful viewing as a filesystem (e.g. N64 ROMs due to the lack of a standard filesystem) 15 | 16 | ## Adding a new type 17 | Each mount type is stored in `ninfs/mount/{type_name}.py`. "`type_name`" is a name such as `cia`, `nandctr` or `sd`. 18 | 19 | ### Creating the module 20 | There might be templates here later. For now, the best place would be to copy another module. 21 | 22 | ### Adding the module 23 | The central module that describes all the mount types is in `ninfs/mountinfo.py`. 24 | 1. Add an entry to the `types` dict. Example: 25 | ```python 26 | 'romfs': { 27 | 'name': 'Read-only Filesystem', 28 | 'info': '".romfs", "romfs.bin"' 29 | }, 30 | ``` 31 | The key name must match the mount type. `name` should include the type's full name, including the console if necessary to distinguish it. `info` usually should show a quoted, comma-separated list of common file names and/or file extensions, but it can show other important files if necessary. 32 | 1. Add any appropriate aliases. This could include different file extensions (e.g. `mount_3ds` is an alias for `mount_cci` since both are extensions of the same file type) or consoles (e.g. `mount_nandhac` and `mount_nandswitch`). 33 | 1. Add it to the appropriate category, or create one if needed. 34 | 35 | Now ninfs will be able to show it in the default output (`python3 -m ninfs`), it can be used as a mount type (`python3 -m ninfs romfs`), it will generate aliases when installed (e.g. `mount_romfs`), and will be included in a build produced by cx_Freeze. 36 | 37 | It will also show up in the GUI, however it won't work properly until a setup wizard module is created for it. 38 | 39 | ### Creating a GUI wizard 40 | Designing a wizard module is a bit more complicated. A template for this might also be provided later. For now, copy one that works the closest to what the mount module needs. 41 | 42 | #### Adding the wizard module 43 | 1. Edit `ninfs/gui/setupwizard/__init__.py` and add an import for the setup class. 44 | 1. Edit `ninfs/gui/wizardcontainer.py` and add the mount name + setup class to `wizard_bases`. (The setup class is already imported from the above with a wildcard import.) 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2021 Ian Burgwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ninfs 2 | ninfs (formerly fuse-3ds) is a FUSE program to extract data from Nintendo game consoles. It works by presenting a virtual filesystem with the contents of your games, NAND, or SD card contents, and you can browse and copy out just the files that you need. 3 | 4 | Windows, macOS, and Linux are supported. 5 | 6 |

7 | 8 | ## Supported types 9 | * Nintendo 3DS 10 | * CTR Cart Image (".3ds", ".cci") 11 | * CDN contents ("cetk", "tmd", and contents) 12 | * CTR Importable Archive (".cia") 13 | * Executable Filesystem (".exefs", "exefs.bin") 14 | * Nintendo 3DS NAND backup ("nand.bin") 15 | * NCCH (".cxi", ".cfa", ".ncch", ".app") 16 | * Read-only Filesystem (".romfs", "romfs.bin") 17 | * SD Card Contents ("Nintendo 3DS" from SD) 18 | * Installed SD Title Contents ("\*.tmd" and "\*.app" files) 19 | * 3DSX Homebrew (".3dsx") 20 | * Nintendo DS / DSi 21 | * Nintendo DSi NAND backup ("nand\_dsi.bin") 22 | * Nintendo DS ROM image (".nds", ".srl") 23 | * iQue Player 24 | * iQue Player NAND backup (read-only) ("nand.bin") 25 | * Nintendo Switch 26 | * Nintendo Switch NAND backup ("rawnand.bin") 27 | 28 | ## Example uses 29 | * Mount a NAND backup and browse CTRNAND, TWLNAND, and others, and write back to them without having to extract and decrypt them first. 30 | * Browse decrypted SD card contents. Dump installed games and saves, or copy contents between two system's SD contents. 31 | * Extract a game's files out of a CIA, CCI (".3ds"), NCCH, RomFS, raw CDN contents, just by mounting them and browsing its files. Or use the virtual decrypted file and start playing the game in a 3DS emulator right away. 32 | 33 | ## Setup 34 | For 3DS types, The ARM9 BootROM is required. You can dump it using boot9strap, which can be set up by [3DS Hacks Guide](https://3ds.hacks.guide). To dump Boot9, follow the steps on [wiki.hacks.guide](https://wiki.hacks.guide/wiki/3DS:Dump_system_files). 35 | 36 | Boot9 is checked in order of: 37 | * `--boot9` argument (if set) 38 | * `BOOT9_PATH` environment variable (if set) 39 | * `%APPDATA%\3ds\boot9.bin` (Windows-specific) 40 | * `~/Library/Application Support/3ds/boot9.bin` (macOS-specific) 41 | * `~/.3ds/boot9.bin` 42 | * `~/3ds/boot9.bin` 43 | 44 | For historical reasons, `boot9_prot.bin` can also be used in all of these locations. 45 | 46 | "`~`" means the user's home directory. "`~/3ds`" would mean `/Users/username/3ds` on macOS and `C:\Users\username\3ds` on Windows. 47 | 48 | CDN, CIA, and NCCH mounting may need [SeedDB](https://github.com/ihaveamac/3DS-rom-tools/wiki/SeedDB-list) for mounting NCCH containers of newer games (2015+) that use seeds. 49 | SeedDB is checked in order of: 50 | * `--seeddb` argument (if set) 51 | * `SEEDDB_PATH` environment variable (if set) 52 | * `%APPDATA%\3ds\seeddb.bin` (Windows-specific) 53 | * `~/Library/Application Support/3ds/seeddb.bin` (macOS-specific) 54 | * `~/.3ds/seeddb.bin` 55 | * `~/3ds/seeddb.bin` 56 | 57 | Python 3.8.0 or later is required. 58 | 59 | ### Windows 60 | Windows 10 or later is recommended. Windows 7 and 8.1 may continue to work until ninfs's dependencies stop working on it. 61 | 62 | #### Installer 63 | An installer is provided in [releases](https://github.com/ihaveamac/ninfs/releases). It includes both ninfs and WinFsp, which is installed if required. 64 | 65 | #### Standalone release 66 | A standalone zip is also provided in [releases](https://github.com/ihaveamac/ninfs/releases). [WinFsp](https://winfsp.dev/rel/) must be installed separately. 67 | 68 | #### Install with existing Python 69 | * Install a version of [Python 3.8 or later](https://www.python.org/downloads/). The x86-64 version is preferred on 64-bit Windows. 70 | * Python from the Microsoft Store is not recommended due to sandboxing restrictioons. 71 | * Install the latest version of [WinFsp](https://winfsp.dev/rel/). 72 | * Install ninfs with `py -3 -m pip install --upgrade https://github.com/ihaveamac/ninfs/archive/2.0.zip` 73 | 74 | #### Windows on ARM 75 | Official support for Windows on ARM will come [eventually](https://github.com/ihaveamac/ninfs/issues/91). In the meantime, running the x86 version has been tested and seems to work properly. Make sure to install the latest WinFSP version with ARM64 support. 76 | 77 | ### macOS 78 | Versions of macOS supported by Apple are highly recommended. macOS Sierra is the oldest version that should work. [macFUSE](https://osxfuse.github.io/) or [fuse-t](https://www.fuse-t.org) is required. 79 | 80 | #### Standalone application 81 | A standalone build is provided in [releases](https://github.com/ihaveamac/ninfs/releases). macFUSE or fuse-t must still be installed separately. Releases are built for Intel and Apple Silicon, signed and notarized by Apple. 82 | 83 | #### Install with existing Python 84 | * Install a version of Python 3.8 or later. Various methods to use Python: 85 | * [Installers from python.org](https://www.python.org/downloads/macos/). 86 | * Xcode or Command Line Tools (note: has a broken GUI) 87 | * [Homebrew](https://brew.sh) 88 | * [MacPorts](https://www.macports.org/) 89 | * [Nix](https://nixos.org/) 90 | * Install the latest version of [macFUSE](https://github.com/osxfuse/osxfuse/releases/latest) or [fuse-t](https://www.fuse-t.org). 91 | * Install ninfs with `python3 -m pip install --upgrade https://github.com/ihaveamac/ninfs/archive/2.0.zip` 92 | 93 | ### Linux 94 | #### Arch Linux 95 | ninfs is available in the AUR: [normal](https://aur.archlinux.org/packages/ninfs/), [with gui](https://aur.archlinux.org/packages/ninfs-gui/), [git](https://aur.archlinux.org/packages/ninfs-git/), [git with gui](https://aur.archlinux.org/packages/ninfs-gui-git/) 96 | 97 | #### Other distributions 98 | * Recent distributions (e.g. Ubuntu 18.04 and later) should have Python 3.8.0 or later pre-installed, or included in its repositories. Refer to your distribution's documentation for details. 99 | * Most distributions should have libfuse enabled/installed by default. Use your package manager if it isn't. 100 | * Install ninfs with `python3 -m pip install --upgrade --user https://github.com/ihaveamac/ninfs/archive/2.0.zip` 101 | * `--user` is not needed if you are using a virtual environment. 102 | * You can add a desktop entry with `python3 -m ninfs --install-desktop-entry`. If you want to install to a location other than the default (`$XDG_DATA_HOME`), you can add another argument with a path like `/usr/local/share`. 103 | * To use the GUI, tkinter needs to be installed. On Debian-/Ubuntu-based systems this is `python3-tk`. On Fedora this is `python3-tkinter`. 104 | 105 | ### Nix/NixOS 106 | A nix derivation is provided, tested on NixOS, other Linux distributions, and macOS. 107 | 108 | On macOS, macFUSE or fuse-t must be installed separately, as nixpkgs doesn't (and probably can't) package either. 109 | 110 | With flakes to run the latest commit on main: 111 | * Use GUI: `nix run github:ihaveamac/ninfs` 112 | * Directly use mount (example with cia): `nix run github:ihaveamac/ninfs -- cia game.cia mountpoint` 113 | 114 | ## Usage 115 | ### Graphical user interface 116 | A GUI can be used by specifying the type to be `gui` (e.g. Windows: `py -3 -mninfs gui`, Linux/macOS: `python3 -mninfs gui`). The GUI controls mounting and unmounting. 117 | 118 | ### Command line 119 | Run a mount script by using "`mount_`" (e.g. `mount_cci game.3ds mountpoint`). Use `-h` to view arguments for a script. 120 | 121 | If it doesn't work, the other way is to use ` -mninfs ` (e.g. Windows: `py -3 -mninfs cci game.3ds mountpoint`, Linux/macOS: `python3 -mninfs cci game.3ds mountpoint`). 122 | 123 | Windows users can use a drive letter like `F:` as a mountpoint, or use `*` and a drive letter will be automatically chosen. 124 | 125 | Developer-unit contents are encrypted with different keys, which can be used with `--dev` with CCI, CDN, CIA, NANDCTR, NCCH, and SD. These are less tested and may have bugs due to unknown differences between retail and dev files. 126 | 127 | #### Unmounting 128 | * Windows: Press Ctrl + C in the command prompt/PowerShell window. 129 | * macOS: Two methods: 130 | * Right-click on the mount and choose "Eject “_drive name_”". 131 | * Run from terminal: `diskutil unmount /path/to/mount` 132 | * Linux: Run from terminal: `fusermount -u /path/to/mount` 133 | 134 | ### Examples 135 | * 3DS game card dump: 136 | `mount_cci game.3ds mountpoint` 137 | * Contents downloaded from CDN: 138 | `mount_cdn cdn_directory mountpoint` 139 | * CDN contents with a specific decrypted titlekey: 140 | `mount_cdn --dec-key 3E3E6769742E696F2F76416A65423C3C cdn_directory mountpoint` 141 | * CIA: 142 | `mount_cia game.cia mountpoint` 143 | * ExeFS: 144 | `mount_exefs exefs.bin mountpoint` 145 | * 3DS NAND backup with `essential.exefs` embedded: 146 | `mount_nandctr nand.bin mountpoint` 147 | * 3DS NAND backup with an OTP file (Counter is automatically generated): 148 | `mount_nandctr --otp otp.bin nand.bin mountpoint` 149 | * 3DS NAND backup with OTP and CID files: 150 | `mount_nandctr --otp otp.bin --cid nand_cid.bin nand.bin mountpoint` 151 | * 3DS NAND backup with OTP file and a CID hexstring: 152 | `mount_nandctr --otp otp.bin --cid 7468616E6B7334636865636B696E6721 nand.bin mountpoint` 153 | * DSi NAND backup (Counter is automatically generated): 154 | `mount_nandtwl --console-id 5345445543454D45 nand_dsi.bin mountpoint` 155 | * DSi NAND backup with a Console ID hexstring and specified CID hexstring: 156 | `mount_nandtwl --console-id 5345445543454D45 --cid 576879446F657344536945786973743F nand_dsi.bin mountpoint` 157 | * DSi NAND backup with a Console ID file and specified CID file: 158 | `mount_nandtwl --console-id ConsoleID.bin --cid CID.bin nand_dsi.bin mountpoint` 159 | * iQue Player NAND backup: 160 | `mount_nandbb nand.bin mountpoint` 161 | * Switch NAND backup: 162 | `mount_nandhac --keys prod.keys rawnand.bin mountpoint` 163 | * Switch NAND backup in multiple parts: 164 | `mount_nandhac --keys prod.keys -S rawnand.bin.00 mountpoint` 165 | * Switch NAND encrypted partition dump: 166 | `mount_nandhac --keys prod.keys --partition SYSTEM SYSTEM.bin mountpoint` 167 | * NCCH container (.app, .cxi, .cfa, .ncch): 168 | `mount_ncch content.cxi mountpoint` 169 | * RomFS: 170 | `mount_romfs romfs.bin mountpoint` 171 | * `Nintendo 3DS` directory from an SD card: 172 | `mount_sd --movable movable.sed "/path/to/Nintendo 3DS" mountpoint` 173 | * `Nintendo 3DS` directory from an SD card with an SD key hexstring: 174 | `mount_sd --sd-key 504C415900000000504F4B454D4F4E21 "/path/to/Nintendo 3DS" mountpoint` 175 | * Nintendo DS ROM image (NDS/SRL, `mount_nds` also works): 176 | `mount_srl game.nds mountpoint` 177 | * 3DSX homebrew application: 178 | `mount_threedsx boot.3dsx mountpoint` 179 | 180 | ## Useful tools 181 | * wwylele's [3ds-save-tool](https://github.com/wwylele/3ds-save-tool) can be used to extract game saves and extra data (DISA and DIFF, respectively). 182 | * wwylele's [save3ds](https://github.com/wwylele/save3ds) is a tool to interact with 3DS save files and extdata. Extracting and importing works on all platforms. The FUSE part only works on macOS and Linux. 183 | * [OSFMount](https://www.osforensics.com/tools/mount-disk-images.html) for Windows can mount FAT12/FAT16/FAT32 partitions in NAND backups. 184 | 185 | ## Related tools 186 | * roothorick's [BUSEHAC](https://gitlab.com/roothorick/busehac) is a Linux driver for encrypted Nintendo Switch NANDs. 187 | * Maschell's [fuse-wiiu](https://github.com/Maschell/fuse-wiiu) can be used to mount Wii U contents. 188 | * koolkdev's [wfs-tools](https://github.com/koolkdev/wfs-tools) has wfs-fuse to mount the Wii U mlc dumps and usb devices. 189 | 190 | # License/Credits 191 | * `ninfs` is under the MIT license. 192 | 193 | Special thanks to @Jhynjhiruu for adding support for iQue Player NAND backups. 194 | 195 | Special thanks to @Stary2001 for help with NAND crypto (especially TWL), and @d0k3 for SD crypto. 196 | 197 | OTP code is from [Stary2001/3ds\_tools](https://github.com/Stary2001/3ds_tools/blob/10b74fee927f66865b97fd73b3e7392e81a3099f/three_ds/aesengine.py), and is under the MIT license. 198 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs ? import { }, 3 | # just so i can use the same pinned version as the flake... 4 | pyctr ? ( 5 | let 6 | flakeLock = builtins.fromJSON (builtins.readFile ./flake.lock); 7 | pyctr-repo = import (builtins.fetchTarball ( 8 | with flakeLock.nodes.pyctr.locked; 9 | { 10 | url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz"; 11 | } 12 | )) { inherit pkgs; }; 13 | in 14 | pyctr-repo.pyctr 15 | ), 16 | }: 17 | 18 | rec { 19 | haccrypto = pkgs.python3Packages.callPackage ./nix/haccrypto.nix { }; 20 | mfusepy = pkgs.python3Packages.callPackage ./nix/mfusepy.nix { }; 21 | ninfs = pkgs.python3Packages.callPackage ./package.nix { 22 | inherit pyctr; 23 | haccrypto = haccrypto; 24 | mfusepy = mfusepy; 25 | }; 26 | ninfsNoGUI = ninfs.override { withGUI = false; }; 27 | #ninfsNoGUI = pkgs.python3Packages.callPackage ./ninfs.nix { haccrypto = haccrypto; mfusepy = mfusepy; withGUI = false; }; 28 | } 29 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1743014863, 6 | "narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "pyctr": { 20 | "inputs": { 21 | "nixpkgs": [ 22 | "nixpkgs" 23 | ] 24 | }, 25 | "locked": { 26 | "lastModified": 1743113777, 27 | "narHash": "sha256-5f6YJgox7FPJcofmMOHdqP/vPj3LnoCNWqcahRpcVls=", 28 | "owner": "ihaveamac", 29 | "repo": "pyctr", 30 | "rev": "c5caca8d897da2e32ce859275f6382e0411a2d08", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "ihaveamac", 35 | "ref": "master", 36 | "repo": "pyctr", 37 | "type": "github" 38 | } 39 | }, 40 | "root": { 41 | "inputs": { 42 | "nixpkgs": "nixpkgs", 43 | "pyctr": "pyctr" 44 | } 45 | } 46 | }, 47 | "root": "root", 48 | "version": 7 49 | } 50 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "ninfs"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | pyctr.url = "github:ihaveamac/pyctr/master"; 7 | pyctr.inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | 10 | outputs = 11 | inputs@{ 12 | self, 13 | nixpkgs, 14 | pyctr, 15 | }: 16 | let 17 | systems = [ 18 | "x86_64-linux" 19 | "i686-linux" 20 | "x86_64-darwin" 21 | "aarch64-darwin" 22 | "aarch64-linux" 23 | "armv6l-linux" 24 | "armv7l-linux" 25 | ]; 26 | forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system); 27 | in 28 | { 29 | legacyPackages = forAllSystems ( 30 | system: 31 | (import ./default.nix { 32 | pkgs = import nixpkgs { inherit system; }; 33 | pyctr = pyctr.packages.${system}.pyctr; 34 | }) 35 | // { 36 | default = self.legacyPackages.${system}.ninfs; 37 | } 38 | ); 39 | packages = forAllSystems ( 40 | system: nixpkgs.lib.filterAttrs (_: v: nixpkgs.lib.isDerivation v) self.legacyPackages.${system} 41 | ); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /ninfs/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | __author__ = 'ihaveamac' 8 | __copyright__ = 'Copyright (c) 2017-2021 Ian Burgwin' 9 | __license__ = 'MIT' 10 | __version__ = '2.0' 11 | -------------------------------------------------------------------------------- /ninfs/__main__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from os.path import dirname, realpath 8 | from sys import argv, exit, path 9 | 10 | # path fun times 11 | path.insert(0, dirname(realpath(__file__))) 12 | 13 | from main import exit_print_types, mount, print_version 14 | if len(argv) < 2: 15 | print_version() 16 | exit_print_types() 17 | 18 | exit(mount(argv.pop(1).lower())) 19 | -------------------------------------------------------------------------------- /ninfs/_frozen_main.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import sys 8 | from os.path import dirname, join 9 | from os import environ 10 | 11 | if len(sys.argv) > 1: 12 | # Ignore `-psn_0_#######` which is added if macOS App Translocation is in effect 13 | if sys.argv[1].startswith('-psn'): 14 | del sys.argv[1] 15 | 16 | if getattr(sys, 'frozen', False): 17 | if hasattr(sys, '_MEIPASS'): 18 | # PyInstaller 19 | pass 20 | else: 21 | # cx_Freeze probably 22 | sys.path.insert(0, join(dirname(sys.executable), 'lib', 'ninfs')) 23 | 24 | if sys.platform == 'win32': 25 | # this will try to fix loading tkinter in paths containing non-latin characters 26 | environ['TCL_LIBRARY'] = 'lib/tkinter/tcl8.6' 27 | 28 | from main import gui 29 | gui() 30 | -------------------------------------------------------------------------------- /ninfs/fmt_detect.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from typing import TYPE_CHECKING 8 | 9 | if TYPE_CHECKING: 10 | from typing import Optional 11 | 12 | 13 | def detect_format(header: bytes) -> 'Optional[str]': 14 | """Attempt to detect the format of a file format based on the 0x200 header.""" 15 | if len(header) not in {0x200, 0x400}: 16 | raise RuntimeError('given header is not 0x200 or 0x400 bytes') 17 | 18 | magic_0x100 = header[0x100:0x104] 19 | if magic_0x100 == b'NCCH': 20 | return 'ncch' 21 | 22 | elif magic_0x100 == b'NCSD': 23 | if header[0x108:0x110] == b'\0' * 8: 24 | return 'nandctr' 25 | else: 26 | return 'cci' 27 | 28 | elif header[0:4] == b'IVFC' or header[0:4] == bytes.fromhex('28000000'): 29 | # IVFC magic, or hardcoded romfs header size 30 | return 'romfs' 31 | 32 | elif header[0:0x10] == bytes.fromhex('20200000 00000000 000A0000 50030000'): 33 | # hardcoded header, type, version, cert chain, ticket sizes (should never change in practice) 34 | return 'cia' 35 | 36 | elif header[0xC0:0xC8] == bytes.fromhex('24FFAE51 699AA221'): 37 | return 'srl' 38 | 39 | elif header[0:4] == b'3DSX': 40 | return 'threedsx' 41 | 42 | # Not entirely sure if this is always the same. 43 | # https://dsibrew.org/wiki/Bootloader#Stage_2 44 | elif header[0x220:0x240] == bytes.fromhex('00080000 10640200 00807B03 00660200 ' 45 | '006E0200 88750200 00807B03 00760200'): 46 | return 'nandtwl' 47 | 48 | # exefs is last because it's the hardest to do 49 | # this should work with any official files 50 | for offs in range(0, 0xA0, 0x10): 51 | try: 52 | header[offs:offs + 8].decode('ascii') 53 | except UnicodeDecodeError: 54 | return None 55 | # if decoding all of them worked... 56 | return 'exefs' 57 | -------------------------------------------------------------------------------- /ninfs/gui/about.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import sys 8 | import tkinter as tk 9 | import tkinter.ttk as ttk 10 | import webbrowser 11 | from os.path import join 12 | from importlib.metadata import metadata 13 | from typing import TYPE_CHECKING 14 | 15 | from .osver import get_os_ver 16 | # "from .. import" didn't work :/ 17 | from __init__ import __copyright__ as ninfs_copyright 18 | from __init__ import __version__ as ninfs_version 19 | 20 | frozen_using = None 21 | if getattr(sys, 'frozen', False): 22 | if getattr(sys, '_MEIPASS', False): 23 | frozen_using = 'PyInstaller' 24 | else: 25 | # cx-Freeze probably 26 | frozen_using = 'cx-Freeze' 27 | 28 | if TYPE_CHECKING: 29 | from . import NinfsGUI 30 | 31 | pad = 10 32 | 33 | python_version = sys.version.split()[0] 34 | pybits = 64 if sys.maxsize > 0xFFFFFFFF else 32 35 | os_ver = get_os_ver() 36 | 37 | 38 | class LicenseViewer(ttk.Frame): 39 | def __init__(self, parent: 'tk.BaseWidget' = None, *, text: str): 40 | super().__init__(parent) 41 | 42 | self.rowconfigure(0, weight=1) 43 | self.columnconfigure(0, weight=1) 44 | self.columnconfigure(1, weight=0) 45 | 46 | scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) 47 | scrollbar.grid(row=0, column=1, sticky=tk.NSEW) 48 | 49 | textarea = tk.Text(self, wrap='word', yscrollcommand=scrollbar.set) 50 | textarea.grid(row=0, column=0, sticky=tk.NSEW) 51 | 52 | scrollbar.configure(command=textarea.yview) 53 | 54 | textarea.insert(tk.END, text) 55 | 56 | textarea.configure(state=tk.DISABLED) 57 | 58 | 59 | class NinfsAbout(tk.Toplevel): 60 | def __init__(self, parent: 'NinfsGUI' = None): 61 | super().__init__(parent) 62 | self.parent = parent 63 | 64 | self.wm_withdraw() 65 | self.parent.set_icon(self) 66 | self.wm_transient(self.parent) 67 | 68 | container = ttk.Frame(self) 69 | container.pack(fill=tk.BOTH, expand=True) 70 | 71 | self.wm_title('About ninfs') 72 | self.wm_resizable(width=tk.FALSE, height=tk.FALSE) 73 | 74 | header_label = ttk.Label(container, text=f'ninfs {ninfs_version}', font=(None, 15, 'bold')) 75 | header_label.grid(row=0, column=0, padx=pad, pady=pad, sticky=tk.W) 76 | 77 | running_on = f'Running on Python {python_version} {pybits}-bit' 78 | if frozen_using: 79 | running_on += f' (frozen using {frozen_using})' 80 | version_label = ttk.Label(container, text=running_on) 81 | version_label.grid(row=1, column=0, padx=pad, pady=(0, pad), sticky=tk.W) 82 | 83 | copyright_label = ttk.Label(container, text='This program uses several libraries and modules, which have ' 84 | 'their licenses below.') 85 | copyright_label.grid(row=2, column=0, padx=pad, pady=(0, pad), sticky=tk.W) 86 | 87 | # tab name, license file name, url 88 | info = [ 89 | (f'ninfs', ninfs_version, 'ninfs.md', 'https://github.com/ihaveamac/ninfs', 90 | 'ninfs - Copyright (c) 2017-2021 Ian Burgwin'), 91 | (f'WinFsp', '2023', 'winfsp.txt', 'https://github.com/billziss-gh/winfsp', 92 | 'WinFsp - Windows File System Proxy, Copyright (C) Bill Zissimopoulos'), 93 | (f'pycryptodomex', metadata('pycryptodomex')['Version'], 'pycryptodome.rst', 94 | 'https://github.com/Legrandin/pycryptodome', 'PyCryptodome - multiple licenses'), 95 | (f'pyctr', metadata('pyctr')['Version'], 'pyctr', 'https://github.com/ihaveamac/pyctr', 96 | 'pyctr - Copyright (c) 2017-2021 Ian Burgwin'), 97 | ('haccrypto', metadata('haccrypto')['Version'], 'haccrypto.md', 'https://github.com/luigoalma/haccrypto', 98 | 'haccrypto - Copyright (c) 2017-2021 Ian Burgwin & Copyright (c) 2020-2021 Luis Marques') 99 | ] 100 | 101 | license_notebook = ttk.Notebook(container) 102 | license_notebook.grid(row=3, column=0, padx=pad, pady=(0, pad)) 103 | 104 | def cmd_maker(do_url): 105 | def func(): 106 | webbrowser.open(do_url) 107 | return func 108 | 109 | for proj_name, proj_version, license_file, url, header in info: 110 | frame = ttk.Frame(license_notebook) 111 | license_notebook.add(frame, text=proj_name + ' ' + proj_version) 112 | 113 | license_header_label = ttk.Label(frame, text=header) 114 | license_header_label.grid(row=0, sticky=tk.W, padx=pad//2, pady=pad//2) 115 | 116 | url_button = ttk.Button(frame, 117 | text='Open website - ' + url, 118 | command=cmd_maker(url)) 119 | url_button.grid(row=1) 120 | 121 | with open(parent.get_data_file(join('licenses', license_file)), 'r', encoding='utf-8') as f: 122 | license_frame = LicenseViewer(frame, text=f.read()) 123 | license_frame.grid(row=2) 124 | 125 | self.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) 126 | 127 | self.wm_deiconify() 128 | -------------------------------------------------------------------------------- /ninfs/gui/confighandler.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from configparser import ConfigParser 8 | from os import environ, makedirs 9 | from os.path import expanduser, isdir, join 10 | from sys import platform 11 | from threading import Lock 12 | 13 | __all__ = ['get_bool', 'set_bool'] 14 | 15 | CONFIG_FILENAME = 'config.ini' 16 | 17 | home = expanduser('~') 18 | 19 | lock = Lock() 20 | 21 | if platform == 'win32': 22 | config_dir = join(environ['APPDATA'], 'ninfs') 23 | elif platform == 'darwin': 24 | config_dir = join(home, 'Library', 'Application Support', 'ninfs') 25 | else: 26 | # probably linux or bsd or something 27 | # if by some chance an OS uses different paths, feel free to let me know or make a PR 28 | config_root = environ.get('XDG_CONFIG_HOME') 29 | if not config_root: 30 | # check other paths in XDG_CONFIG_DIRS to see if ninfs already exists in one of them 31 | config_roots = environ.get('XDG_CONFIG_DIRS') 32 | if not config_roots: 33 | config_roots = '/etc/xdg' 34 | config_paths = config_roots.split(':') 35 | for path in config_paths: 36 | d = join(path, 'ninfs') 37 | if isdir(d): 38 | config_root = d 39 | break 40 | # check again to see if it was set 41 | if not config_root: 42 | config_root = join(home, '.config') 43 | config_dir = join(config_root, 'ninfs') 44 | 45 | makedirs(config_dir, exist_ok=True) 46 | 47 | config_file = join(config_dir, CONFIG_FILENAME) 48 | 49 | parser = ConfigParser() 50 | 51 | # defaults 52 | parser['update'] = {} 53 | parser['update']['onlinecheck'] = 'false' 54 | parser['internal'] = {} 55 | parser['internal']['askedonlinecheck'] = 'false' 56 | 57 | 58 | def save_config(): 59 | with lock: 60 | print('Saving to:', config_file) 61 | with open(config_file, 'w') as f: 62 | parser.write(f) 63 | 64 | 65 | def get_bool(section: 'str', key: 'str'): 66 | return parser.getboolean(section, key) 67 | 68 | 69 | def set_bool(section: 'str', key: 'str', value: bool): 70 | parser.set(section, key, 'true' if value else 'false') 71 | save_config() 72 | 73 | 74 | # load user config if possible 75 | loaded = parser.read(config_file) 76 | if not loaded: 77 | save_config() 78 | -------------------------------------------------------------------------------- /ninfs/gui/data/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/1024x1024.png -------------------------------------------------------------------------------- /ninfs/gui/data/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/128x128.png -------------------------------------------------------------------------------- /ninfs/gui/data/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/16x16.png -------------------------------------------------------------------------------- /ninfs/gui/data/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/32x32.png -------------------------------------------------------------------------------- /ninfs/gui/data/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/64x64.png -------------------------------------------------------------------------------- /ninfs/gui/data/licenses/haccrypto.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2020 Ian Burgwin 4 | Copyright (c) 2020 Luis Marques 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /ninfs/gui/data/licenses/ninfs.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2021 Ian Burgwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ninfs/gui/data/licenses/pycryptodome.rst: -------------------------------------------------------------------------------- 1 | The source code in PyCryptodome is partially in the public domain 2 | and partially released under the BSD 2-Clause license. 3 | 4 | In either case, there are minimal if no restrictions on the redistribution, 5 | modification and usage of the software. 6 | 7 | Public domain 8 | ============= 9 | 10 | All code originating from PyCrypto is free and unencumbered software 11 | released into the public domain. 12 | 13 | Anyone is free to copy, modify, publish, use, compile, sell, or 14 | distribute this software, either in source code form or as a compiled 15 | binary, for any purpose, commercial or non-commercial, and by any 16 | means. 17 | 18 | In jurisdictions that recognize copyright laws, the author or authors 19 | of this software dedicate any and all copyright interest in the 20 | software to the public domain. We make this dedication for the benefit 21 | of the public at large and to the detriment of our heirs and 22 | successors. We intend this dedication to be an overt act of 23 | relinquishment in perpetuity of all present and future rights to this 24 | software under copyright law. 25 | 26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 27 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 28 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 29 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 30 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 31 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 32 | OTHER DEALINGS IN THE SOFTWARE. 33 | 34 | For more information, please refer to 35 | 36 | BSD license 37 | =========== 38 | 39 | All direct contributions to PyCryptodome are released under the following 40 | license. The copyright of each piece belongs to the respective author. 41 | 42 | Redistribution and use in source and binary forms, with or without 43 | modification, are permitted provided that the following conditions are met: 44 | 45 | 1. Redistributions of source code must retain the above copyright notice, 46 | this list of conditions and the following disclaimer. 47 | 48 | 2. Redistributions in binary form must reproduce the above copyright notice, 49 | this list of conditions and the following disclaimer in the documentation 50 | and/or other materials provided with the distribution. 51 | 52 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 53 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 54 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 55 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 56 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 57 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 58 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 59 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 60 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 61 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 62 | 63 | OCB license 64 | =========== 65 | 66 | The OCB cipher mode is patented in the US under patent numbers 7,949,129 and 67 | 8,321,675. The directory Doc/ocb contains three free licenses for implementors 68 | and users. As a general statement, OCB can be freely used for software not meant 69 | for military purposes. Contact your attorney for further information. 70 | -------------------------------------------------------------------------------- /ninfs/gui/data/licenses/pyctr: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2021 Ian Burgwin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ninfs/gui/data/windows.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/gui/data/windows.ico -------------------------------------------------------------------------------- /ninfs/gui/opendir.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from subprocess import check_call, CalledProcessError 8 | from sys import platform 9 | 10 | is_windows = platform == 'win32' 11 | is_mac = platform == 'darwin' 12 | 13 | if is_windows: 14 | from os import startfile 15 | 16 | 17 | def open_directory(path: str): 18 | if is_windows: 19 | startfile(path) 20 | elif is_mac: 21 | try: 22 | check_call(['/usr/bin/open', '-a', 'Finder', path]) 23 | except CalledProcessError as e: 24 | return e.returncode 25 | else: 26 | # assuming linux for this, feel free to add an exception if another OS has a different method 27 | try: 28 | check_call(['xdg-open', path]) 29 | except CalledProcessError as e: 30 | return e.returncode 31 | -------------------------------------------------------------------------------- /ninfs/gui/optionsframes.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | import tkinter.ttk as ttk 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from typing import Dict, List, Tuple 13 | 14 | 15 | class CheckbuttonContainer(ttk.Frame): 16 | def __init__(self, parent: 'tk.BaseWidget' = None, *, options: 'List[str]', enabled: 'List[str]' = None): 17 | super().__init__(parent) 18 | if not enabled: 19 | enabled = [] 20 | 21 | self.variables = {} 22 | for opt in options: 23 | var = tk.BooleanVar(self) 24 | cb = ttk.Checkbutton(self, variable=var, text=opt) 25 | cb.pack(side=tk.LEFT) 26 | if opt in enabled: 27 | var.set(True) 28 | 29 | self.variables[opt] = var 30 | 31 | def get_values(self) -> 'Dict[str, bool]': 32 | return {x: y.get() for x, y in self.variables.items()} 33 | 34 | 35 | class RadiobuttonContainer(ttk.Frame): 36 | def __init__(self, parent: 'tk.BaseWidget' = None, *, options: 'List[Tuple[str, str]]', 37 | default: 'str'): 38 | super().__init__(parent) 39 | 40 | self.variable = tk.StringVar(self) 41 | for idx, opt in enumerate(options): 42 | rb = ttk.Radiobutton(self, variable=self.variable, text=opt[0], value=opt[1]) 43 | rb.grid(row=idx, column=0, sticky=tk.W) 44 | 45 | self.variable.set(default) 46 | 47 | def get_selected(self): 48 | return self.variable.get() 49 | -------------------------------------------------------------------------------- /ninfs/gui/osver.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import platform 8 | import sys 9 | 10 | 11 | def get_os_ver(): 12 | if sys.platform == 'win32': 13 | import winreg 14 | 15 | ver = platform.win32_ver() 16 | marketing_ver = ver[0] 17 | service_pack = ver[2] 18 | 19 | os_ver = 'Windows ' + marketing_ver 20 | if marketing_ver == '10': 21 | k = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows NT\CurrentVersion') 22 | try: 23 | r = winreg.QueryValueEx(k, 'ReleaseId')[0] 24 | except FileNotFoundError: 25 | r = '1507' # assuming RTM, since this key only exists in 1511 and up 26 | os_ver += ', version ' + r 27 | else: 28 | if service_pack != 'SP0': 29 | os_ver += ' ' + service_pack 30 | elif sys.platform == 'darwin': 31 | ver = platform.mac_ver() 32 | os_ver = f'macOS {ver[0]} on {ver[2]}' 33 | else: 34 | ver = platform.uname() 35 | os_ver = f'{ver.system} {ver.release} on {ver.machine}' 36 | 37 | return os_ver 38 | -------------------------------------------------------------------------------- /ninfs/gui/outputviewer.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | import tkinter.ttk as ttk 9 | from typing import TYPE_CHECKING 10 | 11 | if TYPE_CHECKING: 12 | from typing import Iterable 13 | 14 | 15 | class OutputViewer(ttk.Frame): 16 | def __init__(self, parent: 'tk.BaseWidget' = None, *, output: 'Iterable[str]'): 17 | super().__init__(parent) 18 | 19 | self.rowconfigure(0, weight=1) 20 | self.columnconfigure(0, weight=1) 21 | self.columnconfigure(1, weight=0) 22 | 23 | scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) 24 | scrollbar.grid(row=0, column=1, sticky=tk.NSEW) 25 | 26 | textarea = tk.Text(self, wrap='word', yscrollcommand=scrollbar.set) 27 | textarea.grid(row=0, column=0, sticky=tk.NSEW) 28 | 29 | scrollbar.configure(command=textarea.yview) 30 | 31 | for line in output: 32 | textarea.insert(tk.END, line + '\n') 33 | 34 | textarea.see(tk.END) 35 | textarea.configure(state=tk.DISABLED) 36 | -------------------------------------------------------------------------------- /ninfs/gui/settings.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | import tkinter.ttk as ttk 9 | from typing import TYPE_CHECKING 10 | 11 | from .confighandler import get_bool, set_bool 12 | from .optionsframes import CheckbuttonContainer 13 | 14 | if TYPE_CHECKING: 15 | from . import NinfsGUI 16 | 17 | # update options 18 | CHECK_ONLINE = 'Check for updates on GitHub' 19 | 20 | 21 | class NinfsSettings(tk.Toplevel): 22 | def __init__(self, parent: 'NinfsGUI'): 23 | super().__init__(parent) 24 | self.parent = parent 25 | 26 | self.wm_withdraw() 27 | self.parent.set_icon(self) 28 | self.wm_transient(self.parent) 29 | self.grab_set() 30 | self.wm_title('Settings') 31 | 32 | outer_container = ttk.Frame(self) 33 | outer_container.pack() 34 | 35 | update_check_frame = ttk.LabelFrame(outer_container, text='Updates') 36 | update_check_frame.pack(padx=10, pady=10) 37 | 38 | update_options = [CHECK_ONLINE] 39 | enabled = [] 40 | if get_bool('update', 'onlinecheck'): 41 | enabled.append(CHECK_ONLINE) 42 | self.update_options_frame = CheckbuttonContainer(update_check_frame, options=update_options, enabled=enabled) 43 | self.update_options_frame.pack(padx=5, pady=5) 44 | 45 | footer_buttons = ttk.Frame(outer_container) 46 | footer_buttons.pack(padx=10, pady=(0, 10), side=tk.RIGHT) 47 | 48 | ok_button = ttk.Button(footer_buttons, text='OK', command=self.ok) 49 | ok_button.pack(side=tk.RIGHT) 50 | 51 | cancel_button = ttk.Button(footer_buttons, text='Cancel', command=self.cancel) 52 | cancel_button.pack(side=tk.RIGHT) 53 | 54 | self.wm_protocol('WM_DELETE_WINDOW', self.cancel) 55 | 56 | self.geometry("+%d+%d" % (parent.winfo_rootx() + 50, parent.winfo_rooty() + 50)) 57 | 58 | self.wm_deiconify() 59 | 60 | def cancel(self): 61 | self.destroy() 62 | 63 | def ok(self): 64 | values = self.update_options_frame.get_values() 65 | set_bool('update', 'onlinecheck', values[CHECK_ONLINE]) 66 | self.destroy() 67 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from .base import WizardBase 8 | 9 | from .cci import CCISetup 10 | from .cdn import CDNSetup 11 | from .cia import CIASetup 12 | from .exefs import ExeFSSetup 13 | from .nandctr import CTRNandImageSetup 14 | from .nandhac import HACNandImageSetup 15 | from .nandtwl import TWLNandImageSetup 16 | from .nandbb import BBNandImageSetup 17 | from .ncch import NCCHSetup 18 | from .romfs import RomFSSetup 19 | from .sd import SDFilesystemSetup 20 | from .sdtitle import SDTitleSetup 21 | from .srl import SRLSetup 22 | from .threedsx import ThreeDSXSetup 23 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/base.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | import tkinter.ttk as ttk 9 | import tkinter.filedialog as fd 10 | from typing import TYPE_CHECKING 11 | 12 | from ..optionsframes import CheckbuttonContainer 13 | 14 | if TYPE_CHECKING: 15 | from typing import List, Tuple 16 | from ..wizardcontainer import WizardContainer 17 | 18 | 19 | class WizardBase(ttk.Frame): 20 | def __init__(self, parent: 'tk.BaseWidget' = None, *args, wizardcontainer: 'WizardContainer', **kwargs): 21 | super().__init__(parent) 22 | self.parent = parent 23 | self.wizardcontainer = wizardcontainer 24 | 25 | def next_pressed(self): 26 | print('This should not be seen') 27 | 28 | def set_header(self, text: str): 29 | self.wizardcontainer.header.configure(text=text) 30 | 31 | def set_header_suffix(self, text: str): 32 | self.set_header('Mount new content - ' + text) 33 | 34 | def make_container(self, labeltext: str) -> 'ttk.Frame': 35 | container = ttk.Frame(self) 36 | 37 | container.rowconfigure(0, weight=1) 38 | container.rowconfigure(1, weight=1) 39 | container.columnconfigure(0, weight=1) 40 | container.columnconfigure(1, weight=0) 41 | 42 | label = ttk.Label(container, text=labeltext, justify=tk.LEFT) 43 | label.grid(row=0, column=0, columnspan=2, pady=(4, 0), sticky=tk.EW) 44 | 45 | return container 46 | 47 | def make_entry(self, labeltext: str, default: str = None) -> 'Tuple[ttk.Frame, ttk.Entry, tk.StringVar]': 48 | container = self.make_container(labeltext) 49 | 50 | textbox_var = tk.StringVar(self) 51 | 52 | textbox = ttk.Entry(container, textvariable=textbox_var) 53 | textbox.grid(row=1, column=0, columnspan=2, pady=4, sticky=tk.EW) 54 | 55 | if default: 56 | textbox_var.set(default) 57 | 58 | return container, textbox, textbox_var 59 | 60 | def make_file_picker(self, labeltext: str, fd_title: str, 61 | default: str = None) -> 'Tuple[ttk.Frame, ttk.Entry, tk.StringVar]': 62 | container = self.make_container(labeltext) 63 | 64 | textbox_var = tk.StringVar(self) 65 | 66 | textbox = ttk.Entry(container, textvariable=textbox_var) 67 | textbox.grid(row=1, column=0, pady=4, sticky=tk.EW) 68 | 69 | if default: 70 | textbox_var.set(default) 71 | 72 | def choose_file(): 73 | f = fd.askopenfilename(parent=self.parent, title=fd_title) 74 | if f: 75 | textbox_var.set(f) 76 | 77 | filepicker_button = ttk.Button(container, text='...', command=choose_file) 78 | filepicker_button.grid(row=1, column=1, pady=4) 79 | 80 | return container, textbox, textbox_var 81 | 82 | def make_directory_picker(self, labeltext: str, fd_title: str, 83 | default: str = None) -> 'Tuple[ttk.Frame, ttk.Entry, tk.StringVar]': 84 | container = self.make_container(labeltext) 85 | 86 | textbox_var = tk.StringVar(self) 87 | 88 | textbox = ttk.Entry(container, textvariable=textbox_var) 89 | textbox.grid(row=1, column=0, pady=4, sticky=tk.EW) 90 | 91 | if default: 92 | textbox_var.set(default) 93 | 94 | def choose_file(): 95 | f = fd.askdirectory(parent=self.parent, title=fd_title) 96 | if f: 97 | textbox_var.set(f) 98 | 99 | directorypicker_button = ttk.Button(container, text='...', command=choose_file) 100 | directorypicker_button.grid(row=1, column=1, pady=4) 101 | 102 | return container, textbox, textbox_var 103 | 104 | def make_option_menu(self, labeltext: str, *options) -> 'Tuple[ttk.Frame, ttk.OptionMenu, tk.StringVar]': 105 | container = self.make_container(labeltext) 106 | 107 | optionmenu_variable = tk.StringVar(self) 108 | 109 | default = None 110 | if options: 111 | default = options[0] 112 | 113 | optionmenu = ttk.OptionMenu(container, optionmenu_variable, default, *options) 114 | optionmenu.grid(row=1, column=0, columnspan=2, pady=4, sticky=tk.EW) 115 | 116 | return container, optionmenu, optionmenu_variable 117 | 118 | def make_checkbox_options(self, labeltext: str, options: 'List[str]'): 119 | container = ttk.Frame(self) 120 | label = ttk.Label(container, text=labeltext) 121 | label.grid(row=0, column=0, padx=(0, 4), sticky=tk.NW) 122 | 123 | cb = CheckbuttonContainer(container, options=options) 124 | cb.grid(row=0, column=1, sticky=tk.W) 125 | 126 | return container, cb 127 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/cci.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class CCISetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the 3DS/CCI file:', 27 | 'Select 3DS/CCI file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | self.main_textbox_var = main_textbox_var 34 | self.b9_textbox_var = b9_textbox_var 35 | 36 | main_textbox_var.trace_add('write', callback) 37 | b9_textbox_var.trace_add('write', callback) 38 | 39 | b9_textbox_var.set(supportfiles.last_b9_file) 40 | 41 | self.set_header_suffix('3DS/CCI') 42 | 43 | def next_pressed(self): 44 | main_file = self.main_textbox_var.get().strip() 45 | b9_file = self.b9_textbox_var.get().strip() 46 | 47 | if b9_file: 48 | supportfiles.last_b9_file = b9_file 49 | 50 | args = ['cci', main_file] 51 | if b9_file: 52 | args += ['--boot9', b9_file] 53 | 54 | self.wizardcontainer.show_mount_point_selector('3DS/CCI', args) 55 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/cdn.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class CDNSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the CDN TMD file:', 27 | 'Select CDN TMD file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | seeddb_container, seeddb_textbox, seeddb_textbox_var = self.make_file_picker('Select the seeddb file:', 34 | 'Select seeddb file') 35 | seeddb_container.pack(fill=tk.X, expand=True) 36 | 37 | seed_container, seed_textbox, seed_textbox_var = self.make_entry('OR Enter the seed ' 38 | '(optional if seeddb is used):') 39 | seed_container.pack(fill=tk.X, expand=True) 40 | 41 | self.main_textbox_var = main_textbox_var 42 | self.b9_textbox_var = b9_textbox_var 43 | self.seeddb_textbox_var = seeddb_textbox_var 44 | self.seed_textbox_var = seed_textbox_var 45 | 46 | main_textbox_var.trace_add('write', callback) 47 | b9_textbox_var.trace_add('write', callback) 48 | 49 | b9_textbox_var.set(supportfiles.last_b9_file) 50 | seeddb_textbox_var.set(supportfiles.last_seeddb_file) 51 | 52 | self.set_header_suffix('CDN') 53 | 54 | def next_pressed(self): 55 | main_file = self.main_textbox_var.get().strip() 56 | b9_file = self.b9_textbox_var.get().strip() 57 | seeddb_file = self.seeddb_textbox_var.get().strip() 58 | seed = self.seed_textbox_var.get().replace(' ', '') 59 | 60 | if b9_file: 61 | supportfiles.last_b9_file = b9_file 62 | if seeddb_file: 63 | supportfiles.last_seeddb_file = seeddb_file 64 | 65 | args = ['cdn', main_file] 66 | if b9_file: 67 | args += ['--boot9', b9_file] 68 | if seed: 69 | args += ['--seed', seed] 70 | elif seeddb_file: 71 | args += ['--seeddb', seeddb_file] 72 | 73 | self.wizardcontainer.show_mount_point_selector('CDN', args) 74 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/cia.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class CIASetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the CIA file:', 27 | 'Select CIA file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | seeddb_container, seeddb_textbox, seeddb_textbox_var = self.make_file_picker('Select the seeddb file:', 34 | 'Select seeddb file') 35 | seeddb_container.pack(fill=tk.X, expand=True) 36 | 37 | seed_container, seed_textbox, seed_textbox_var = self.make_entry('OR Enter the seed ' 38 | '(optional if seeddb is used):') 39 | seed_container.pack(fill=tk.X, expand=True) 40 | 41 | self.main_textbox_var = main_textbox_var 42 | self.b9_textbox_var = b9_textbox_var 43 | self.seeddb_textbox_var = seeddb_textbox_var 44 | self.seed_textbox_var = seed_textbox_var 45 | 46 | main_textbox_var.trace_add('write', callback) 47 | b9_textbox_var.trace_add('write', callback) 48 | 49 | b9_textbox_var.set(supportfiles.last_b9_file) 50 | seeddb_textbox_var.set(supportfiles.last_seeddb_file) 51 | 52 | self.set_header_suffix('CIA') 53 | 54 | def next_pressed(self): 55 | main_file = self.main_textbox_var.get().strip() 56 | b9_file = self.b9_textbox_var.get().strip() 57 | seeddb_file = self.seeddb_textbox_var.get().strip() 58 | seed = self.seed_textbox_var.get().replace(' ', '') 59 | 60 | if b9_file: 61 | supportfiles.last_b9_file = b9_file 62 | if seeddb_file: 63 | supportfiles.last_seeddb_file = seeddb_file 64 | 65 | args = ['cia', main_file] 66 | if b9_file: 67 | args += ['--boot9', b9_file] 68 | if seed: 69 | args += ['--seed', seed] 70 | elif seeddb_file: 71 | args += ['--seeddb', seeddb_file] 72 | 73 | self.wizardcontainer.show_mount_point_selector('CIA', args) 74 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/exefs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class ExeFSSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | self.wizardcontainer.set_next_enabled(main_file) 23 | 24 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the ExeFS file:', 25 | 'Select ExeFS file') 26 | main_container.pack(fill=tk.X, expand=True) 27 | 28 | self.main_textbox_var = main_textbox_var 29 | 30 | main_textbox_var.trace_add('write', callback) 31 | 32 | self.set_header_suffix('ExeFS') 33 | 34 | def next_pressed(self): 35 | main_file = self.main_textbox_var.get().strip() 36 | 37 | args = ['exefs', main_file] 38 | 39 | self.wizardcontainer.show_mount_point_selector('ExeFS', args) 40 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/nandbb.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class BBNandImageSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | self.wizardcontainer.set_next_enabled(main_file) 23 | 24 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the NAND file:', 25 | 'Select NAND file') 26 | main_container.pack(fill=tk.X, expand=True) 27 | 28 | self.main_textbox_var = main_textbox_var 29 | 30 | main_textbox_var.trace_add('write', callback) 31 | 32 | self.set_header_suffix('iQue Player NAND') 33 | 34 | def next_pressed(self): 35 | main_file = self.main_textbox_var.get().strip() 36 | 37 | args = ['nandbb', main_file] 38 | 39 | self.wizardcontainer.show_mount_point_selector('iQue Player NAND', args) 40 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/nandctr.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class CTRNandImageSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the NAND file:', 27 | 'Select NAND file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | labeltext = 'Select the OTP file (not required if GodMode9 essentials.exefs is embedded):' 34 | otp_container, otp_textbox, otp_textbox_var = self.make_file_picker(labeltext, 'Select OTP file') 35 | otp_container.pack(fill=tk.X, expand=True) 36 | 37 | options_frame, cb_container = self.make_checkbox_options('Options:', ['Allow writing']) 38 | options_frame.pack(fill=tk.X, expand=True) 39 | self.cb_container = cb_container 40 | 41 | self.main_textbox_var = main_textbox_var 42 | self.b9_textbox_var = b9_textbox_var 43 | self.otp_textbox_var = otp_textbox_var 44 | 45 | main_textbox_var.trace_add('write', callback) 46 | b9_textbox_var.trace_add('write', callback) 47 | 48 | b9_textbox_var.set(supportfiles.last_b9_file) 49 | 50 | self.set_header_suffix('Nintendo 3DS NAND') 51 | 52 | def next_pressed(self): 53 | main_file = self.main_textbox_var.get().strip() 54 | b9_file = self.b9_textbox_var.get().strip() 55 | otp_file = self.otp_textbox_var.get().strip() 56 | 57 | if b9_file: 58 | supportfiles.last_b9_file = b9_file 59 | 60 | args = ['nandctr', main_file] 61 | opts = self.cb_container.get_values() 62 | if not opts['Allow writing']: 63 | args.append('-r') 64 | if b9_file: 65 | args += ['--boot9', b9_file] 66 | if otp_file: 67 | args += ['--otp', otp_file] 68 | 69 | self.wizardcontainer.show_mount_point_selector('3DS NAND', args) 70 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/nandhac.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class HACNandImageSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | keys_file = self.keys_textbox_var.get().strip() 23 | self.wizardcontainer.set_next_enabled(main_file and keys_file) 24 | 25 | filetypes = [ 26 | 'Full NAND image', 27 | 'PRODINFO', 28 | 'PRODINFOF', 29 | 'SAFE', 30 | 'SYSTEM', 31 | 'USER', 32 | ] 33 | filetype_container, filetype_menu, filetype_menu_var = self.make_option_menu('Select the file type:', 34 | *filetypes) 35 | filetype_container.pack(fill=tk.X, expand=True) 36 | 37 | labeltext = 'Select the NAND file (full or split):' 38 | main_container, main_textbox, main_textbox_var = self.make_file_picker(labeltext, 'Select NAND file') 39 | main_container.pack(fill=tk.X, expand=True) 40 | 41 | keys_container, keys_textbox, keys_textbox_var = self.make_file_picker('Select the BIS keys file:', 42 | 'Select BIS keys file') 43 | keys_container.pack(fill=tk.X, expand=True) 44 | 45 | options_frame, cb_container = self.make_checkbox_options('Options:', ['Allow writing']) 46 | options_frame.pack(fill=tk.X, expand=True) 47 | self.cb_container = cb_container 48 | 49 | self.main_textbox_var = main_textbox_var 50 | self.keys_textbox_var = keys_textbox_var 51 | self.filetype_var = filetype_menu_var 52 | 53 | main_textbox_var.trace_add('write', callback) 54 | keys_textbox_var.trace_add('write', callback) 55 | 56 | self.set_header_suffix('Nintendo Switch NAND') 57 | 58 | def next_pressed(self): 59 | main_file = self.main_textbox_var.get().strip() 60 | keys_file = self.keys_textbox_var.get().strip() 61 | filetype = self.filetype_var.get() 62 | 63 | args = ['nandhac', main_file] 64 | if main_file[-3] == '.': 65 | try: 66 | int(main_file[-2:]) 67 | except ValueError: 68 | # not a split file 69 | pass 70 | else: 71 | # is a split file 72 | args.append('--split-files') 73 | opts = self.cb_container.get_values() 74 | if not opts['Allow writing']: 75 | args.append('-r') 76 | if keys_file: 77 | args += ['--keys', keys_file] 78 | if filetype != 'Full NAND image': 79 | args += ['--partition', filetype] 80 | 81 | self.wizardcontainer.show_mount_point_selector('Switch NAND', args) 82 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/nandtwl.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class TWLNandImageSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | self.wizardcontainer.set_next_enabled(main_file) 24 | 25 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the NAND file:', 26 | 'Select NAND file') 27 | main_container.pack(fill=tk.X, expand=True) 28 | 29 | labeltext = 'Enter the Console ID (not required if nocash footer is embedded)' 30 | consoleid_container, consoleid_textbox, consoleid_textbox_var = self.make_entry(labeltext) 31 | consoleid_container.pack(fill=tk.X, expand=True) 32 | 33 | options_frame, cb_container = self.make_checkbox_options('Options:', ['Allow writing']) 34 | options_frame.pack(fill=tk.X, expand=True) 35 | self.cb_container = cb_container 36 | 37 | self.main_textbox_var = main_textbox_var 38 | self.consoleid_textbox_var = consoleid_textbox_var 39 | 40 | main_textbox_var.trace_add('write', callback) 41 | consoleid_textbox_var.trace_add('write', callback) 42 | 43 | self.set_header_suffix('Nintendo DSi NAND') 44 | 45 | def next_pressed(self): 46 | main_file = self.main_textbox_var.get().strip() 47 | consoleid = self.consoleid_textbox_var.get().strip() 48 | 49 | args = ['nandtwl', main_file] 50 | opts = self.cb_container.get_values() 51 | if not opts['Allow writing']: 52 | args.append('-r') 53 | if consoleid: 54 | args += ['--console-id', consoleid] 55 | 56 | self.wizardcontainer.show_mount_point_selector('DSi NAND', args) 57 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/ncch.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class NCCHSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the NCCH file:', 27 | 'Select NCCH file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | seeddb_container, seeddb_textbox, seeddb_textbox_var = self.make_file_picker('Select the seeddb file:', 34 | 'Select seeddb file') 35 | seeddb_container.pack(fill=tk.X, expand=True) 36 | 37 | seed_container, seed_textbox, seed_textbox_var = self.make_entry('OR Enter the seed ' 38 | '(optional if seeddb is used):') 39 | seed_container.pack(fill=tk.X, expand=True) 40 | 41 | self.main_textbox_var = main_textbox_var 42 | self.b9_textbox_var = b9_textbox_var 43 | self.seeddb_textbox_var = seeddb_textbox_var 44 | self.seed_textbox_var = seed_textbox_var 45 | 46 | main_textbox_var.trace_add('write', callback) 47 | b9_textbox_var.trace_add('write', callback) 48 | 49 | b9_textbox_var.set(supportfiles.last_b9_file) 50 | seeddb_textbox_var.set(supportfiles.last_seeddb_file) 51 | 52 | self.set_header_suffix('NCCH') 53 | 54 | def next_pressed(self): 55 | main_file = self.main_textbox_var.get().strip() 56 | b9_file = self.b9_textbox_var.get().strip() 57 | seeddb_file = self.seeddb_textbox_var.get().strip() 58 | seed = self.seed_textbox_var.get().replace(' ', '') 59 | 60 | if b9_file: 61 | supportfiles.last_b9_file = b9_file 62 | if seeddb_file: 63 | supportfiles.last_seeddb_file = seeddb_file 64 | 65 | args = ['ncch', main_file] 66 | if b9_file: 67 | args += ['--boot9', b9_file] 68 | if seed: 69 | args += ['--seed', seed] 70 | elif seeddb_file: 71 | args += ['--seeddb', seeddb_file] 72 | 73 | self.wizardcontainer.show_mount_point_selector('NCCH', args) 74 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/romfs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class RomFSSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | self.wizardcontainer.set_next_enabled(main_file) 23 | 24 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the RomFS file:', 25 | 'Select RomFS file') 26 | main_container.pack(fill=tk.X, expand=True) 27 | 28 | self.main_textbox_var = main_textbox_var 29 | 30 | main_textbox_var.trace_add('write', callback) 31 | 32 | self.set_header_suffix('RomFS') 33 | 34 | def next_pressed(self): 35 | main_file = self.main_textbox_var.get().strip() 36 | 37 | args = ['romfs', main_file] 38 | 39 | self.wizardcontainer.show_mount_point_selector('RomFS', args) 40 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/sd.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class SDFilesystemSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | movable_file = self.movable_textbox_var.get().strip() 25 | self.wizardcontainer.set_next_enabled(main_file and b9_file and movable_file) 26 | 27 | labeltext = 'Select the "Nintendo 3DS" directory:' 28 | main_container, main_textbox, main_textbox_var = self.make_directory_picker(labeltext, 29 | 'Select "Nintendo 3DS" directory') 30 | main_container.pack(fill=tk.X, expand=True) 31 | 32 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 33 | b9_container.pack(fill=tk.X, expand=True) 34 | 35 | movable_container, movable_textbox, movable_textbox_var = self.make_file_picker('Select the movable.sed file:', 36 | 'Select movable.sed file') 37 | movable_container.pack(fill=tk.X, expand=True) 38 | 39 | options_container, options_frame = self.make_checkbox_options('Options:', ['Allow writing']) 40 | options_container.pack(fill=tk.X, expand=True) 41 | 42 | self.main_textbox_var = main_textbox_var 43 | self.b9_textbox_var = b9_textbox_var 44 | self.movable_textbox_var = movable_textbox_var 45 | self.options_frame = options_frame 46 | 47 | main_textbox_var.trace_add('write', callback) 48 | b9_textbox_var.trace_add('write', callback) 49 | movable_textbox_var.trace_add('write', callback) 50 | 51 | b9_textbox_var.set(supportfiles.last_b9_file) 52 | 53 | self.set_header_suffix('Nintendo 3DS SD Card') 54 | 55 | def next_pressed(self): 56 | main_file = self.main_textbox_var.get().strip() 57 | b9_file = self.b9_textbox_var.get().strip() 58 | movable_file = self.movable_textbox_var.get().strip() 59 | 60 | if b9_file: 61 | supportfiles.last_b9_file = b9_file 62 | 63 | args = ['sd', main_file] 64 | opts = self.options_frame.get_values() 65 | if not opts['Allow writing']: 66 | args.append('-r') 67 | if b9_file: 68 | args += ['--boot9', b9_file] 69 | if movable_file: 70 | args += ['--movable', movable_file] 71 | 72 | self.wizardcontainer.show_mount_point_selector('3DS SD', args) 73 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/sdtitle.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | from .. import supportfiles 12 | 13 | if TYPE_CHECKING: 14 | from .. import WizardContainer 15 | 16 | 17 | class SDTitleSetup(WizardBase): 18 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 19 | super().__init__(parent, wizardcontainer=wizardcontainer) 20 | 21 | def callback(*_): 22 | main_file = self.main_textbox_var.get().strip() 23 | b9_file = self.b9_textbox_var.get().strip() 24 | self.wizardcontainer.set_next_enabled(main_file and b9_file) 25 | 26 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the SD Title TMD file:', 27 | 'Select SD Title TMD file') 28 | main_container.pack(fill=tk.X, expand=True) 29 | 30 | b9_container, b9_textbox, b9_textbox_var = self.make_file_picker('Select the boot9 file:', 'Select boot9 file') 31 | b9_container.pack(fill=tk.X, expand=True) 32 | 33 | seeddb_container, seeddb_textbox, seeddb_textbox_var = self.make_file_picker('Select the seeddb file:', 34 | 'Select seeddb file') 35 | seeddb_container.pack(fill=tk.X, expand=True) 36 | 37 | seed_container, seed_textbox, seed_textbox_var = self.make_entry('OR Enter the seed ' 38 | '(optional if seeddb is used):') 39 | seed_container.pack(fill=tk.X, expand=True) 40 | 41 | self.main_textbox_var = main_textbox_var 42 | self.b9_textbox_var = b9_textbox_var 43 | self.seeddb_textbox_var = seeddb_textbox_var 44 | self.seed_textbox_var = seed_textbox_var 45 | 46 | main_textbox_var.trace_add('write', callback) 47 | b9_textbox_var.trace_add('write', callback) 48 | 49 | b9_textbox_var.set(supportfiles.last_b9_file) 50 | seeddb_textbox_var.set(supportfiles.last_seeddb_file) 51 | 52 | self.set_header_suffix('SD Title') 53 | 54 | def next_pressed(self): 55 | main_file = self.main_textbox_var.get().strip() 56 | b9_file = self.b9_textbox_var.get().strip() 57 | seeddb_file = self.seeddb_textbox_var.get().strip() 58 | seed = self.seed_textbox_var.get().replace(' ', '') 59 | 60 | if b9_file: 61 | supportfiles.last_b9_file = b9_file 62 | if seeddb_file: 63 | supportfiles.last_seeddb_file = seeddb_file 64 | 65 | args = ['sdtitle', main_file] 66 | if b9_file: 67 | args += ['--boot9', b9_file] 68 | if seed: 69 | args += ['--seed', seed] 70 | elif seeddb_file: 71 | args += ['--seeddb', seeddb_file] 72 | 73 | self.wizardcontainer.show_mount_point_selector('SD Title', args) 74 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/srl.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class SRLSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | self.wizardcontainer.set_next_enabled(main_file) 23 | 24 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the NDS/SRL file:', 25 | 'Select NDS/SRL file') 26 | main_container.pack(fill=tk.X, expand=True) 27 | 28 | self.main_textbox_var = main_textbox_var 29 | 30 | main_textbox_var.trace_add('write', callback) 31 | 32 | self.set_header_suffix('NDS/SRL') 33 | 34 | def next_pressed(self): 35 | main_file = self.main_textbox_var.get().strip() 36 | 37 | args = ['srl', main_file] 38 | 39 | self.wizardcontainer.show_mount_point_selector('NDS/SRL', args) 40 | -------------------------------------------------------------------------------- /ninfs/gui/setupwizard/threedsx.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import tkinter as tk 8 | from typing import TYPE_CHECKING 9 | 10 | from .base import WizardBase 11 | 12 | if TYPE_CHECKING: 13 | from .. import WizardContainer 14 | 15 | 16 | class ThreeDSXSetup(WizardBase): 17 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer'): 18 | super().__init__(parent, wizardcontainer=wizardcontainer) 19 | 20 | def callback(*_): 21 | main_file = self.main_textbox_var.get().strip() 22 | self.wizardcontainer.set_next_enabled(main_file) 23 | 24 | main_container, main_textbox, main_textbox_var = self.make_file_picker('Select the 3dsx file:', 25 | 'Select 3dsx file') 26 | main_container.pack(fill=tk.X, expand=True) 27 | 28 | self.main_textbox_var = main_textbox_var 29 | 30 | main_textbox_var.trace_add('write', callback) 31 | 32 | self.set_header_suffix('3DSX') 33 | 34 | def next_pressed(self): 35 | main_file = self.main_textbox_var.get().strip() 36 | 37 | args = ['threedsx', main_file] 38 | 39 | self.wizardcontainer.show_mount_point_selector('3DSX', args) 40 | -------------------------------------------------------------------------------- /ninfs/gui/supportfiles.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from contextlib import suppress 8 | from os import environ 9 | from os.path import isfile, join 10 | 11 | from pyctr.util import config_dirs 12 | 13 | b9_paths = [] 14 | seeddb_paths = [] 15 | for p in config_dirs: 16 | b9_paths.append(join(p, 'boot9.bin')) 17 | b9_paths.append(join(p, 'boot9_prot.bin')) 18 | seeddb_paths.append(join(p, 'seeddb.bin')) 19 | 20 | with suppress(KeyError): 21 | b9_paths.insert(0, environ['BOOT9_PATH']) 22 | 23 | with suppress(KeyError): 24 | seeddb_paths.insert(0, environ['SEEDDB_PATH']) 25 | 26 | last_b9_file = '' 27 | last_seeddb_file = '' 28 | 29 | for p in b9_paths: 30 | if isfile(p): 31 | last_b9_file = p 32 | break 33 | 34 | for p in seeddb_paths: 35 | if isfile(p): 36 | last_seeddb_file = p 37 | break 38 | -------------------------------------------------------------------------------- /ninfs/gui/updatecheck.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | import json 8 | import tkinter as tk 9 | import tkinter.ttk as ttk 10 | import webbrowser 11 | from typing import TYPE_CHECKING 12 | from urllib.request import urlopen, Request 13 | 14 | from pkg_resources import parse_version 15 | 16 | from .outputviewer import OutputViewer 17 | from .setupwizard import WizardBase 18 | from .wizardcontainer import WizardContainer 19 | 20 | if TYPE_CHECKING: 21 | from typing import Tuple 22 | # noinspection PyProtectedMember 23 | from pkg_resources._vendor.packaging.version import Version 24 | from http.client import HTTPResponse 25 | 26 | from . import NinfsGUI 27 | 28 | from __init__ import __version__ 29 | 30 | version: 'Version' = parse_version(__version__) 31 | 32 | 33 | class UpdateNotificationWindow(WizardBase): 34 | def __init__(self, parent: 'tk.BaseWidget' = None, *, wizardcontainer: 'WizardContainer', releaseinfo: 'dict'): 35 | super().__init__(parent, wizardcontainer=wizardcontainer) 36 | 37 | self.releaseinfo = releaseinfo 38 | 39 | self.set_header('New update available - ' + releaseinfo['tag_name']) 40 | 41 | self.rowconfigure(0, weight=0) 42 | self.rowconfigure(0, weight=1) 43 | self.columnconfigure(1, weight=1) 44 | 45 | label = ttk.Label(self, text='A new update for ninfs is available!') 46 | label.grid(row=0, column=0, sticky=tk.EW) 47 | 48 | rel_body_full = releaseinfo['body'] 49 | rel_body = rel_body_full[:rel_body_full.find('------')].replace('\r\n', '\n').strip() 50 | 51 | viewer = OutputViewer(self, output=[rel_body]) 52 | viewer.grid(row=1, column=0, sticky=tk.NSEW) 53 | 54 | self.wizardcontainer.next_button.configure(text='Open release info') 55 | self.wizardcontainer.set_next_enabled(True) 56 | 57 | def next_pressed(self): 58 | webbrowser.open(self.releaseinfo['html_url']) 59 | 60 | 61 | def get_latest_release() -> 'dict': 62 | url = 'https://api.github.com/repos/ihaveamac/ninfs/releases' 63 | if not version.is_prerelease: 64 | url += '/latest' 65 | 66 | print('UPDATE: Requesting', url) 67 | req = Request(url, headers={'Accept': 'application/vnd.github.v3+json'}) 68 | with urlopen(req) as u: # type: HTTPResponse 69 | data = json.load(u) 70 | 71 | if version.is_prerelease: 72 | data = data[0] 73 | 74 | return data 75 | 76 | 77 | def thread_update_check(gui: 'NinfsGUI'): 78 | rel = get_latest_release() 79 | latest_version: 'Version' = parse_version(rel['tag_name']) 80 | 81 | print('UPDATE: Latest version:', latest_version) 82 | if latest_version > version: 83 | wizard_window = WizardContainer(gui) 84 | wizard_window.change_frame(UpdateNotificationWindow, releaseinfo=rel) 85 | wizard_window.focus() 86 | -------------------------------------------------------------------------------- /ninfs/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This file is a part of ninfs. 4 | # 5 | # Copyright (c) 2017-2021 Ian Burgwin 6 | # This file is licensed under The MIT License (MIT). 7 | # You can find the full license text in LICENSE.md in the root of this project. 8 | 9 | from importlib import import_module 10 | from inspect import cleandoc 11 | from os import environ, makedirs 12 | from os.path import basename, dirname, expanduser, join as pjoin, realpath 13 | from sys import exit, argv, path, platform, hexversion, version_info, executable 14 | 15 | _path = dirname(realpath(__file__)) 16 | if _path not in path: 17 | path.insert(0, _path) 18 | 19 | import mountinfo 20 | 21 | windows = platform in {'win32', 'cygwin'} 22 | macos = platform == 'darwin' 23 | 24 | python_cmd = 'py -3' if windows else 'python3' 25 | 26 | if hexversion < 0x030800F0: 27 | exit('Python {0[0]}.{0[1]}.{0[2]} is not supported. Please use Python 3.8.0 or later.'.format(version_info)) 28 | 29 | 30 | def print_version(): 31 | from __init__ import __version__ 32 | pyver = '{0[0]}.{0[1]}.{0[2]}'.format(version_info) 33 | if version_info[3] != 'final': 34 | pyver += '{0[3][0]}{0[4]}'.format(version_info) 35 | # this should stay as str.format, so it runs on older versions 36 | print('ninfs v{0} on Python {1} - https://github.com/ihaveamac/ninfs'.format(__version__, pyver)) 37 | 38 | 39 | def exit_print_types(): 40 | print('Please provide a mount type as the first argument.') 41 | print('Available mount types:') 42 | print() 43 | for cat, items in mountinfo.categories.items(): 44 | print(cat) 45 | for item in items: 46 | info = mountinfo.get_type_info(item) 47 | print(f' - {item}: {info["name"]} ({info["info"]})') 48 | print() 49 | print('Additional options:') 50 | print(' --version print version') 51 | print(' --install-desktop-entry [PREFIX] create desktop entry (for Linux)') 52 | exit(1) 53 | 54 | 55 | def mount(mount_type: str, return_doc: bool = False) -> int: 56 | if mount_type in {'gui', 'gui_i_want_to_be_an_admin_pls'}: 57 | from gui import start_gui 58 | return start_gui() 59 | 60 | if mount_type in {'-v', '--version'}: 61 | # this kinda feels wrong... 62 | print_version() 63 | return 0 64 | 65 | if mount_type == '--install-desktop-entry': 66 | prefix = None if len(argv) < 2 else argv[1] 67 | create_desktop_entry(prefix, environ.get('NINFS_USE_NINFS_EXECUTABLE_IN_DESKTOP', '')) 68 | return 0 69 | 70 | # noinspection PyProtectedMember 71 | from pyctr.crypto import BootromNotFoundError 72 | 73 | if windows: 74 | from ctypes import windll, get_last_error 75 | 76 | if windll.shell32.IsUserAnAdmin(): 77 | print('- Note: This should *not* be run as an administrator.', 78 | '- The mount will not be normally accessible.', 79 | '- This should be run from a non-administrator command prompt or PowerShell prompt.', sep='\n') 80 | 81 | # this allows for the gui parent process to send signal.CTRL_BREAK_EVENT and for this process to receive it 82 | try: 83 | import os 84 | parent_pid = int(environ['NINFS_GUI_PARENT_PID']) 85 | if windll.kernel32.AttachConsole(parent_pid) == 0: # ATTACH_PARENT_PROCESS 86 | print(f'Failed to do AttachConsole({parent_pid}):', get_last_error()) 87 | print("(Note: this most likely isn't the cause of any other issues you might have!)") 88 | except KeyError: 89 | pass 90 | 91 | if mount_type not in mountinfo.types and mount_type not in mountinfo.aliases: 92 | print_version() 93 | exit_print_types() 94 | 95 | module = import_module('mount.' + mountinfo.aliases.get(mount_type, mount_type)) 96 | if return_doc: 97 | # noinspection PyTypeChecker 98 | return module.__doc__ 99 | 100 | prog = None 101 | if __name__ != '__main__': 102 | prog = 'mount_' + mountinfo.aliases.get(mount_type, mount_type) 103 | try: 104 | # noinspection PyUnresolvedReferences 105 | return module.main(prog=prog) 106 | except BootromNotFoundError as e: 107 | print('Bootrom could not be found.', 108 | 'Please read the README of the repository for more details.', 109 | 'Paths checked:', 110 | *(' - {}'.format(x) for x in e.args[0]), sep='\n') 111 | return 1 112 | except RuntimeError as e: 113 | if e.args == (1,): 114 | pass # assuming failed to mount and the reason would be displayed in the terminal 115 | 116 | 117 | def create_desktop_entry(prefix: str = None, use_ninfs_executable: bool = False): 118 | exec_value = 'ninfs' if use_ninfs_executable else f'{executable} -mninfs gui' 119 | if windows or macos: 120 | print('This command is not supported for Windows or macOS.') 121 | return 122 | desktop_file = cleandoc(f''' 123 | [Desktop Entry] 124 | Name=ninfs 125 | Comment=Mount Nintendo contents 126 | Exec={exec_value} 127 | Terminal=true 128 | Type=Application 129 | Icon=ninfs 130 | Categories=Utility; 131 | ''') 132 | if not prefix: 133 | home = expanduser('~') 134 | prefix = environ.get('XDG_DATA_HOME', pjoin(home, '.local', 'share')) 135 | 136 | app_dir = pjoin(prefix, 'applications') 137 | makedirs(app_dir, exist_ok=True) 138 | 139 | with open(pjoin(app_dir, 'ninfs.desktop'), 'w', encoding='utf-8') as o: 140 | print('Writing', o.name) 141 | o.write(desktop_file) 142 | 143 | for s in ('1024x1024', '128x128', '64x64', '32x32', '16x16'): 144 | img_dir = pjoin(prefix, 'icons', 'hicolor', s, 'apps') 145 | makedirs(img_dir, exist_ok=True) 146 | with open(pjoin(dirname(__file__), 'gui/data', s + '.png'), 'rb') as i, \ 147 | open(pjoin(img_dir, 'ninfs.png'), 'wb') as o: 148 | print('Writing', o.name) 149 | o.write(i.read()) 150 | 151 | 152 | def main(): 153 | exit(mount(basename(argv[0])[6:].lower())) 154 | 155 | 156 | def gui(_allow_admin: bool = False): 157 | if len(argv) < 2 or argv[1] in {'gui', 'gui_i_want_to_be_an_admin_pls'}: 158 | from gui import start_gui 159 | exit(start_gui()) 160 | else: 161 | exit(mount(argv.pop(1).lower())) 162 | 163 | 164 | if __name__ == '__main__': 165 | # path fun times 166 | 167 | if len(argv) < 2: 168 | exit_print_types() 169 | 170 | exit(mount(argv.pop(1).lower())) 171 | -------------------------------------------------------------------------------- /ninfs/mount/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/ninfs/mount/__init__.py -------------------------------------------------------------------------------- /ninfs/mount/cci.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts CTR Cart Image (CCI, ".3ds") files, creating a virtual filesystem of separate partitions. 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from stat import S_IFDIR, S_IFREG 14 | from sys import argv 15 | from typing import TYPE_CHECKING 16 | 17 | from pyctr.type.cci import CCIReader, CCISection 18 | 19 | from . import _common as _c 20 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 21 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \ 22 | realpath 23 | from .ncch import NCCHContainerMount 24 | 25 | if TYPE_CHECKING: 26 | from typing import Dict 27 | 28 | 29 | class CTRCartImageMount(LoggingMixIn, Operations): 30 | fd = 0 31 | 32 | def __init__(self, reader: 'CCIReader', g_stat: dict): 33 | self.dirs: Dict[str, NCCHContainerMount] = {} 34 | self.files: Dict[str, CCISection] = {} 35 | 36 | # get status change, modify, and file access times 37 | self.g_stat = g_stat 38 | 39 | self.reader = reader 40 | 41 | def __del__(self, *args): 42 | try: 43 | self.f.close() 44 | except AttributeError: 45 | pass 46 | 47 | destroy = __del__ 48 | 49 | def init(self, path): 50 | ncsd_part_names = ('game', 'manual', 'dlp', 'unk', 'unk', 'unk', 'update_n3ds', 'update_o3ds') 51 | 52 | def add_file(name: str, section: 'CCISection'): 53 | self.files[name] = section 54 | 55 | add_file('/ncsd.bin', CCISection.Header) 56 | add_file('/cardinfo.bin', CCISection.CardInfo) 57 | add_file('/devinfo.bin', CCISection.DevInfo) 58 | for part, ncch_reader in self.reader.contents.items(): 59 | dirname = f'/content{part}.{ncsd_part_names[part]}' 60 | filename = dirname + '.ncch' 61 | 62 | add_file(filename, part) 63 | try: 64 | mount = NCCHContainerMount(ncch_reader, g_stat=self.g_stat) 65 | mount.init(path) 66 | self.dirs[dirname] = mount 67 | except Exception as e: 68 | print(f'Failed to mount {filename}: {type(e).__name__}: {e}') 69 | 70 | @_c.ensure_lower_path 71 | def getattr(self, path, fh=None): 72 | first_dir = _c.get_first_dir(path) 73 | if first_dir in self.dirs: 74 | return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) 75 | uid, gid, pid = fuse_get_context() 76 | if path == '/': 77 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 78 | elif path in self.files: 79 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.reader.sections[self.files[path]].size, 'st_nlink': 1} 80 | else: 81 | raise FuseOSError(ENOENT) 82 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 83 | 84 | def open(self, path, flags): 85 | self.fd += 1 86 | return self.fd 87 | 88 | @_c.ensure_lower_path 89 | def readdir(self, path, fh): 90 | first_dir = _c.get_first_dir(path) 91 | if first_dir in self.dirs: 92 | yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) 93 | else: 94 | yield from ('.', '..') 95 | yield from (x[1:] for x in self.files) 96 | yield from (x[1:] for x in self.dirs) 97 | 98 | @_c.ensure_lower_path 99 | def read(self, path, size, offset, fh): 100 | first_dir = _c.get_first_dir(path) 101 | if first_dir in self.dirs: 102 | return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) 103 | 104 | section = self.files[path] 105 | with self.reader.open_raw_section(section) as f: 106 | f.seek(offset) 107 | return f.read(size) 108 | 109 | @_c.ensure_lower_path 110 | def statfs(self, path): 111 | first_dir = _c.get_first_dir(path) 112 | if first_dir in self.dirs: 113 | return self.dirs[first_dir].statfs(_c.remove_first_dir(path)) 114 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.reader.image_size // 4096, 'f_bavail': 0, 115 | 'f_bfree': 0, 'f_files': len(self.files)} 116 | 117 | 118 | def main(prog: str = None, args: list = None): 119 | from argparse import ArgumentParser 120 | if args is None: 121 | args = argv[1:] 122 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS CTR Cart Image files.', 123 | parents=(_c.default_argp, _c.ctrcrypto_argp, _c.main_args('cci', 'CCI file'))) 124 | parser.add_argument('--dec', help='assume contents are decrypted', action='store_true') 125 | 126 | a = parser.parse_args(args) 127 | opts = dict(_c.parse_fuse_opts(a.o)) 128 | 129 | if a.do: 130 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 131 | 132 | cci_stat = get_time(a.cci) 133 | 134 | load_custom_boot9(a.boot9) 135 | 136 | with CCIReader(a.cci, dev=a.dev, assume_decrypted=a.dec) as r: 137 | mount = CTRCartImageMount(reader=r, g_stat=cci_stat) 138 | if _c.macos or _c.windows: 139 | opts['fstypename'] = 'CCI' 140 | if _c.macos: 141 | display = r.media_id.upper() 142 | try: 143 | title = r.contents[CCISection.Application].exefs.icon.get_app_title() 144 | display += f'; ' + r.contents[CCISection.Application].product_code 145 | if title.short_desc != 'unknown': 146 | display += '; ' + title.short_desc 147 | except: 148 | pass 149 | opts['volname'] = f'CTR Cart Image ({display})' 150 | elif _c.windows: 151 | # volume label can only be up to 32 chars 152 | try: 153 | title = r.contents[CCISection.Application].exefs.icon.get_app_title().short_desc 154 | if len(title) > 26: 155 | title = title[0:25] + '\u2026' # ellipsis 156 | display = title 157 | except: 158 | display = r.tmd.title_id.upper() 159 | opts['volname'] = f'CCI ({display})' 160 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 161 | fsname=realpath(a.cci).replace(',', '_'), **opts) 162 | -------------------------------------------------------------------------------- /ninfs/mount/cdn.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts raw CDN contents, creating a virtual filesystem of decrypted contents (if encrypted). 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from os.path import isfile, join 14 | from stat import S_IFDIR, S_IFREG 15 | from sys import argv 16 | from typing import TYPE_CHECKING 17 | 18 | from pyctr.crypto import load_seeddb 19 | from pyctr.type.cdn import CDNReader, CDNSection 20 | 21 | from . import _common as _c 22 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 23 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \ 24 | realpath 25 | from .ncch import NCCHContainerMount 26 | from .srl import SRLMount 27 | 28 | if TYPE_CHECKING: 29 | from typing import Dict, Tuple, Union 30 | 31 | 32 | class CDNContentsMount(LoggingMixIn, Operations): 33 | fd = 0 34 | total_size = 0 35 | 36 | def __init__(self, reader: 'CDNReader', g_stat: dict): 37 | self.dirs: Dict[str, Union[NCCHContainerMount, SRLMount]] = {} 38 | self.files: Dict[str, Tuple[Union[int, CDNSection], int, int]] = {} 39 | 40 | # get status change, modify, and file access times 41 | self.g_stat = g_stat 42 | 43 | self.reader = reader 44 | 45 | def __del__(self, *args): 46 | try: 47 | self.reader.close() 48 | except AttributeError: 49 | pass 50 | 51 | destroy = __del__ 52 | 53 | def init(self, path): 54 | def add_file(name: str, section: 'Union[CDNSection, int]', added_offset: int = 0): 55 | # added offset is used for a few things like meta icon and tmdchunks 56 | if section >= 0: 57 | size = self.reader.content_info[section].size 58 | else: 59 | with self.reader.open_raw_section(section) as f: 60 | size = f.seek(0, 2) 61 | self.files[name] = (section, added_offset, size - added_offset) 62 | 63 | if CDNSection.Ticket in self.reader.available_sections: 64 | add_file('/ticket.bin', CDNSection.Ticket) 65 | add_file('/tmd.bin', CDNSection.TitleMetadata) 66 | add_file('/tmdchunks.bin', CDNSection.TitleMetadata, 0xB04) 67 | 68 | for record in self.reader.content_info: 69 | dirname = f'/{record.cindex:04x}.{record.id}' 70 | is_srl = record.cindex == 0 and self.reader.tmd.title_id[3:5] == '48' 71 | file_ext = 'nds' if is_srl else 'ncch' 72 | filename = f'{dirname}.{file_ext}' 73 | add_file(filename, record.cindex) 74 | try: 75 | if is_srl: 76 | # special case for SRL contents 77 | srl_fp = self.reader.open_raw_section(record.cindex) 78 | self.dirs[dirname] = SRLMount(srl_fp, g_stat=self.g_stat) 79 | else: 80 | mount = NCCHContainerMount(self.reader.contents[record.cindex], g_stat=self.g_stat) 81 | mount.init(path) 82 | self.dirs[dirname] = mount 83 | except Exception as e: 84 | print(f'Failed to mount {filename}: {type(e).__name__}: {e}') 85 | 86 | self.total_size += record.size 87 | 88 | @_c.ensure_lower_path 89 | def getattr(self, path, fh=None): 90 | first_dir = _c.get_first_dir(path) 91 | if first_dir in self.dirs: 92 | return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) 93 | uid, gid, pid = fuse_get_context() 94 | if path == '/' or path in self.dirs: 95 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 96 | elif path in self.files: 97 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.files[path][2], 'st_nlink': 1} 98 | else: 99 | raise FuseOSError(ENOENT) 100 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 101 | 102 | def open(self, path, flags): 103 | self.fd += 1 104 | return self.fd 105 | 106 | @_c.ensure_lower_path 107 | def readdir(self, path, fh): 108 | first_dir = _c.get_first_dir(path) 109 | if first_dir in self.dirs: 110 | yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) 111 | else: 112 | yield from ('.', '..') 113 | yield from (x[1:] for x in self.files) 114 | yield from (x[1:] for x in self.dirs) 115 | 116 | @_c.ensure_lower_path 117 | def read(self, path, size, offset, fh): 118 | first_dir = _c.get_first_dir(path) 119 | if first_dir in self.dirs: 120 | return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) 121 | 122 | section = self.files[path] 123 | with self.reader.open_raw_section(section[0]) as f: 124 | f.seek(offset + section[1]) 125 | return f.read(size) 126 | 127 | @_c.ensure_lower_path 128 | def statfs(self, path): 129 | first_dir = _c.get_first_dir(path) 130 | if first_dir in self.dirs: 131 | return self.dirs[first_dir].statfs(_c.remove_first_dir(path)) 132 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.total_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 133 | 'f_files': len(self.files)} 134 | 135 | 136 | def main(prog: str = None, args: list = None): 137 | from argparse import ArgumentParser 138 | if args is None: 139 | args = argv[1:] 140 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS CDN contents.', 141 | parents=(_c.default_argp, _c.ctrcrypto_argp, _c.seeddb_argp, 142 | _c.main_args('content', 'tmd file or directory with CDN contents'))) 143 | parser.add_argument('--dec-key', help='decrypted titlekey') 144 | 145 | a = parser.parse_args(args) 146 | opts = dict(_c.parse_fuse_opts(a.o)) 147 | 148 | if a.do: 149 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 150 | 151 | if isfile(a.content): 152 | tmd_file = a.content 153 | else: 154 | tmd_file = join(a.content, 'tmd') 155 | 156 | cdn_stat = get_time(a.content) 157 | 158 | load_custom_boot9(a.boot9) 159 | 160 | if a.seeddb: 161 | load_seeddb(a.seeddb) 162 | 163 | dec_key = None 164 | if a.dec_key: 165 | dec_key = bytes.fromhex(a.dec_key) 166 | 167 | with CDNReader(tmd_file, dev=a.dev, seed=a.seed, case_insensitive=True, decrypted_titlekey=dec_key) as r: 168 | mount = CDNContentsMount(reader=r, g_stat=cdn_stat) 169 | if _c.macos or _c.windows: 170 | opts['fstypename'] = 'CIA' 171 | if _c.macos: 172 | display = r.tmd.title_id.upper() 173 | try: 174 | title = r.contents[0].exefs.icon.get_app_title() 175 | display += f'; ' + r.contents[0].product_code 176 | if title.short_desc != 'unknown': 177 | display += '; ' + title.short_desc 178 | except: 179 | pass 180 | opts['volname'] = f'CDN Contents ({display})' 181 | elif _c.windows: 182 | # volume label can only be up to 32 chars 183 | try: 184 | title = r.contents[0].exefs.icon.get_app_title().short_desc 185 | if len(title) > 26: 186 | title = title[0:25] + '\u2026' # ellipsis 187 | display = title 188 | except: 189 | display = r.tmd.title_id.upper() 190 | opts['volname'] = f'CDN ({display})' 191 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 192 | fsname=realpath(tmd_file).replace(',', '_'), **opts) 193 | -------------------------------------------------------------------------------- /ninfs/mount/cia.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts CTR Importable Archive (CIA) files, creating a virtual filesystem of decrypted contents (if encrypted) + Ticket, 9 | Title Metadata, and Meta region (if exists). 10 | 11 | DLC with missing contents is currently not supported. 12 | """ 13 | 14 | import logging 15 | from errno import ENOENT 16 | from stat import S_IFDIR, S_IFREG 17 | from sys import argv 18 | from typing import TYPE_CHECKING 19 | 20 | from pyctr.crypto import load_seeddb 21 | from pyctr.type.cia import CIAReader, CIASection 22 | 23 | from . import _common as _c 24 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 25 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \ 26 | realpath 27 | from .ncch import NCCHContainerMount 28 | from .srl import SRLMount 29 | 30 | if TYPE_CHECKING: 31 | from typing import Dict, Tuple, Union 32 | 33 | 34 | class CTRImportableArchiveMount(LoggingMixIn, Operations): 35 | fd = 0 36 | 37 | def __init__(self, reader: 'CIAReader', g_stat: dict): 38 | self.dirs: Dict[str, Union[NCCHContainerMount, SRLMount]] = {} 39 | self.files: Dict[str, Tuple[Union[int, CIASection], int, int]] = {} 40 | 41 | # get status change, modify, and file access times 42 | self.g_stat = g_stat 43 | 44 | self.reader = reader 45 | 46 | def __del__(self, *args): 47 | try: 48 | self.reader.close() 49 | except AttributeError: 50 | pass 51 | 52 | destroy = __del__ 53 | 54 | def init(self, path): 55 | def add_file(name: str, section: 'CIASection', added_offset: int = 0): 56 | # added offset is used for a few things like meta icon and tmdchunks 57 | region = self.reader.sections[section] 58 | self.files[name] = (section, added_offset, region.size - added_offset) 59 | 60 | add_file('/header.bin', CIASection.ArchiveHeader) 61 | add_file('/cert.bin', CIASection.CertificateChain) 62 | add_file('/ticket.bin', CIASection.Ticket) 63 | add_file('/tmd.bin', CIASection.TitleMetadata) 64 | add_file('/tmdchunks.bin', CIASection.TitleMetadata, 0xB04) 65 | if CIASection.Meta in self.reader.sections: 66 | add_file('/meta.bin', CIASection.Meta) 67 | add_file('/icon.bin', CIASection.Meta, 0x400) 68 | 69 | for record in self.reader.content_info: 70 | dirname = f'/{record.cindex:04x}.{record.id}' 71 | is_srl = record.cindex == 0 and self.reader.tmd.title_id[3:5] == '48' 72 | file_ext = 'nds' if is_srl else 'ncch' 73 | filename = f'{dirname}.{file_ext}' 74 | add_file(filename, record.cindex) 75 | try: 76 | if is_srl: 77 | # special case for SRL contents 78 | srl_fp = self.reader.open_raw_section(record.cindex) 79 | self.dirs[dirname] = SRLMount(srl_fp, g_stat=self.g_stat) 80 | else: 81 | mount = NCCHContainerMount(self.reader.contents[record.cindex], g_stat=self.g_stat) 82 | mount.init(path) 83 | self.dirs[dirname] = mount 84 | except Exception as e: 85 | print(f'Failed to mount {filename}: {type(e).__name__}: {e}') 86 | 87 | @_c.ensure_lower_path 88 | def getattr(self, path, fh=None): 89 | first_dir = _c.get_first_dir(path) 90 | if first_dir in self.dirs: 91 | return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) 92 | uid, gid, pid = fuse_get_context() 93 | if path == '/' or path in self.dirs: 94 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 95 | elif path in self.files: 96 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.files[path][2], 'st_nlink': 1} 97 | else: 98 | raise FuseOSError(ENOENT) 99 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 100 | 101 | def open(self, path, flags): 102 | self.fd += 1 103 | return self.fd 104 | 105 | @_c.ensure_lower_path 106 | def readdir(self, path, fh): 107 | first_dir = _c.get_first_dir(path) 108 | if first_dir in self.dirs: 109 | yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) 110 | else: 111 | yield from ('.', '..') 112 | yield from (x[1:] for x in self.files) 113 | yield from (x[1:] for x in self.dirs) 114 | 115 | @_c.ensure_lower_path 116 | def read(self, path, size, offset, fh): 117 | first_dir = _c.get_first_dir(path) 118 | if first_dir in self.dirs: 119 | return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) 120 | 121 | section = self.files[path] 122 | with self.reader.open_raw_section(section[0]) as f: 123 | f.seek(offset + section[1]) 124 | return f.read(size) 125 | 126 | @_c.ensure_lower_path 127 | def statfs(self, path): 128 | first_dir = _c.get_first_dir(path) 129 | if first_dir in self.dirs: 130 | return self.dirs[first_dir].statfs(_c.remove_first_dir(path)) 131 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.reader.total_size // 4096, 'f_bavail': 0, 132 | 'f_bfree': 0, 'f_files': len(self.files)} 133 | 134 | 135 | def main(prog: str = None, args: list = None): 136 | from argparse import ArgumentParser 137 | if args is None: 138 | args = argv[1:] 139 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS CTR Importable Archive files.', 140 | parents=(_c.default_argp, _c.ctrcrypto_argp, _c.seeddb_argp, 141 | _c.main_args('cia', "CIA file"))) 142 | 143 | a = parser.parse_args(args) 144 | opts = dict(_c.parse_fuse_opts(a.o)) 145 | 146 | if a.do: 147 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 148 | 149 | cia_stat = get_time(a.cia) 150 | 151 | load_custom_boot9(a.boot9) 152 | 153 | if a.seeddb: 154 | load_seeddb(a.seeddb) 155 | 156 | with CIAReader(a.cia, dev=a.dev, seed=a.seed) as r: 157 | mount = CTRImportableArchiveMount(reader=r, g_stat=cia_stat) 158 | if _c.macos or _c.windows: 159 | opts['fstypename'] = 'CIA' 160 | if _c.macos: 161 | display = r.tmd.title_id.upper() 162 | try: 163 | title = r.contents[0].exefs.icon.get_app_title() 164 | display += f'; ' + r.contents[0].product_code 165 | if title.short_desc != 'unknown': 166 | display += '; ' + title.short_desc 167 | except: 168 | pass 169 | opts['volname'] = f'CTR Importable Archive ({display})' 170 | elif _c.windows: 171 | # volume label can only be up to 32 chars 172 | try: 173 | title = r.contents[0].exefs.icon.get_app_title().short_desc 174 | if len(title) > 26: 175 | title = title[0:25] + '\u2026' # ellipsis 176 | display = title 177 | except: 178 | display = r.tmd.title_id.upper() 179 | opts['volname'] = f'CIA ({display})' 180 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 181 | fsname=realpath(a.cia).replace(',', '_'), **opts) 182 | -------------------------------------------------------------------------------- /ninfs/mount/exefs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts Executable Filesystem (ExeFS) files, creating a virtual filesystem of the ExeFS contents. 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from itertools import chain 14 | from io import BytesIO 15 | from stat import S_IFDIR, S_IFREG 16 | from sys import argv 17 | from threading import Lock 18 | from typing import TYPE_CHECKING 19 | 20 | import png 21 | from pyctr.type.exefs import ExeFSReader, ExeFSFileNotFoundError, CodeDecompressionError 22 | from pyctr.type.smdh import SMDH, InvalidSMDHError 23 | 24 | from . import _common as _c 25 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 26 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath, basename 27 | 28 | if TYPE_CHECKING: 29 | from typing import BinaryIO, Dict, Union 30 | 31 | 32 | class ExeFSMount(LoggingMixIn, Operations): 33 | fd = 0 34 | files: 'Dict[str, str]' 35 | special_files: 'Dict[str, Dict[str, Union[int, BinaryIO]]]' 36 | 37 | def __init__(self, reader: 'ExeFSReader', g_stat: dict, decompress_code: bool = False): 38 | self.g_stat = g_stat 39 | 40 | self.reader = reader 41 | self.decompress_code = decompress_code 42 | 43 | self.special_files_lock = Lock() 44 | 45 | # for vfs stats 46 | self.exefs_size = sum(x.size for x in self.reader.entries.values()) 47 | 48 | def __del__(self, *args): 49 | try: 50 | self.reader.close() 51 | except AttributeError: 52 | pass 53 | 54 | with self.special_files_lock: 55 | for f in self.special_files.values(): 56 | f['io'].close() 57 | 58 | destroy = __del__ 59 | 60 | # TODO: maybe do this in a way that allows for multiprocessing (titledir) 61 | def init(self, path, data=None): 62 | if self.decompress_code and '.code' in self.reader.entries: 63 | print('ExeFS: Decompressing code...') 64 | try: 65 | res = self.reader.decompress_code() 66 | except CodeDecompressionError as e: 67 | print(f'ExeFS: Failed to decompress code: {e}') 68 | else: 69 | if res: 70 | print('ExeFS: Done!') 71 | else: 72 | print('ExeFS: No decompression needed') 73 | 74 | # displayed name associated with real entry name 75 | self.files = {'/' + x.name.replace('.', '', 1) + '.bin': x.name for x in self.reader.entries.values()} 76 | self.special_files = {} 77 | 78 | if self.reader.icon: 79 | icon_small = BytesIO() 80 | icon_large = BytesIO() 81 | 82 | def load_to_pypng(array, w, h): 83 | return png.from_array((chain.from_iterable(x) for x in array), 'RGB', {'width': w, 'height': h}) 84 | 85 | load_to_pypng(self.reader.icon.icon_small_array, 24, 24).write(icon_small) 86 | load_to_pypng(self.reader.icon.icon_large_array, 48, 48).write(icon_large) 87 | 88 | icon_small_size = icon_small.seek(0, 2) 89 | icon_large_size = icon_large.seek(0, 2) 90 | 91 | # these names are too long to be in the exefs, so special checks can be added for them 92 | self.special_files['/icon_small.png'] = {'size': icon_small_size, 'io': icon_small} 93 | self.special_files['/icon_large.png'] = {'size': icon_large_size, 'io': icon_large} 94 | 95 | @_c.ensure_lower_path 96 | def getattr(self, path, fh=None): 97 | uid, gid, pid = fuse_get_context() 98 | if path == '/': 99 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 100 | else: 101 | if path in self.files: 102 | item = self.reader.entries[self.files[path]] 103 | size = item.size 104 | elif path in self.special_files: 105 | item = self.special_files[path] 106 | size = item['size'] 107 | else: 108 | raise FuseOSError(ENOENT) 109 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': size, 'st_nlink': 1} 110 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 111 | 112 | def open(self, path, flags): 113 | self.fd += 1 114 | return self.fd 115 | 116 | @_c.ensure_lower_path 117 | def readdir(self, path, fh): 118 | yield from ('.', '..') 119 | yield from (x[1:] for x in self.files) 120 | yield from (x[1:] for x in self.special_files) 121 | 122 | @_c.ensure_lower_path 123 | def read(self, path, size, offset, fh): 124 | if path in self.files: 125 | with self.reader.open(self.files[path]) as f: 126 | f.seek(offset) 127 | return f.read(size) 128 | elif path in self.special_files: 129 | with self.special_files_lock: 130 | f = self.special_files[path]['io'] 131 | f.seek(offset) 132 | return f.read(size) 133 | else: 134 | raise FuseOSError(ENOENT) 135 | 136 | @_c.ensure_lower_path 137 | def statfs(self, path): 138 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.exefs_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 139 | 'f_files': len(self.reader) + len(self.special_files)} 140 | 141 | 142 | def main(prog: str = None, args: list = None): 143 | from argparse import ArgumentParser 144 | if args is None: 145 | args = argv[1:] 146 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS Executable Filesystem (ExeFS) files.', 147 | parents=(_c.default_argp, _c.main_args('exefs', 'ExeFS file'))) 148 | parser.add_argument('--decompress-code', help='decompress the .code section', action='store_true') 149 | 150 | a = parser.parse_args(args) 151 | opts = dict(_c.parse_fuse_opts(a.o)) 152 | 153 | if a.do: 154 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 155 | 156 | exefs_stat = get_time(a.exefs) 157 | 158 | with ExeFSReader(a.exefs) as r: 159 | mount = ExeFSMount(reader=r, g_stat=exefs_stat, decompress_code=a.decompress_code) 160 | if _c.macos or _c.windows: 161 | opts['fstypename'] = 'ExeFS' 162 | if _c.macos: 163 | opts['volname'] = f'Nintendo 3DS ExeFS ({basename(a.exefs)})' 164 | elif _c.windows: 165 | # volume label can only be up to 32 chars 166 | opts['volname'] = 'Nintendo 3DS ExeFS' 167 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 168 | fsname=realpath(a.exefs).replace(',', '_'), **opts) 169 | -------------------------------------------------------------------------------- /ninfs/mount/nandbb.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts iQue Player NAND images, creating a virtual filesystem of the files contained within. 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT, EROFS 13 | from stat import S_IFDIR, S_IFREG 14 | from sys import exit, argv 15 | from typing import BinaryIO 16 | 17 | from pyctr.util import readbe 18 | 19 | from . import _common as _c 20 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 21 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath, basename 22 | 23 | class BBNandImageMount(LoggingMixIn, Operations): 24 | fd = 0 25 | 26 | def __init__(self, nand_fp: BinaryIO, g_stat: dict): 27 | self.g_stat = g_stat 28 | 29 | self.files = {} 30 | 31 | self.f = nand_fp 32 | 33 | def __del__(self, *args): 34 | try: 35 | self.f.close() 36 | except AttributeError: 37 | pass 38 | 39 | destroy = __del__ 40 | 41 | def init(self, path): 42 | nand_size = self.f.seek(0, 2) 43 | if nand_size != 0x4000000: 44 | exit(f'NAND size is incorrect (expected 0x4000000, got {nand_size:#X})') 45 | 46 | bbfs_blocks = [] 47 | self.f.seek(0xFF0 * 0x4000) 48 | for i in range(0x10): 49 | bbfs_blocks.append(self.f.read(0x4000)) 50 | 51 | self.f.seek(0) 52 | 53 | latest_seqno = -1 54 | latest_bbfs_block = None 55 | 56 | for i, j in enumerate(bbfs_blocks): 57 | header = j[0x3FF4:] 58 | 59 | magic = header[:4] 60 | if magic == b"\0x00\0x00\0x00\0x00": 61 | continue 62 | if magic not in [b"BBFS", b"BBFL"]: 63 | exit(f'Invalid BBFS magic: expected b"BBFS" or b"BBFL", got {magic.hex().upper()}') 64 | 65 | calculated_checksum = 0 66 | for k in range(0, 0x4000, 2): 67 | calculated_checksum += readbe(j[k:k + 2]) 68 | 69 | if calculated_checksum & 0xFFFF != 0xCAD7: 70 | exit(f'BBFS block {i} has an invalid checksum') 71 | 72 | seqno = readbe(header[4:8]) 73 | if seqno > latest_seqno: 74 | latest_seqno = seqno 75 | latest_bbfs_block = i 76 | 77 | if latest_bbfs_block == None or latest_seqno == -1: 78 | exit(f'Blank BBFS (all BBFS magics were 00000000)') 79 | 80 | self.used = 0 81 | 82 | for i in range(0x2000, 0x3FF4, 0x14): 83 | entry = bbfs_blocks[latest_bbfs_block][i:i + 0x14] 84 | valid = bool(entry[11]) 85 | u = readbe(entry[12:14]) 86 | start = u - (u & 0x8000) * 2 87 | if valid and start != -1: 88 | name = entry[:8].decode().rstrip("\x00") 89 | ext = entry[8:11].decode().rstrip("\x00") 90 | size = readbe(entry[16:20]) 91 | self.files[f'/{name}.{ext}'] = {'start': start, 'size': size} 92 | self.used += size // 0x4000 93 | 94 | fat = bbfs_blocks[latest_bbfs_block][:0x2000] 95 | 96 | self.fat_entries = [] 97 | for i in range(0, len(fat), 2): 98 | u = readbe(fat[i:i + 2]) 99 | s = u - (u & 0x8000) * 2 100 | self.fat_entries.append(s) 101 | 102 | def flush(self, path, fh): 103 | return self.f.flush() 104 | 105 | @_c.ensure_lower_path 106 | def getattr(self, path, fh=None): 107 | uid, gid, pid = fuse_get_context() 108 | if path == '/': 109 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 110 | elif path in self.files: 111 | st = {'st_mode': (S_IFREG | 0o666), 112 | 'st_size': self.files[path]['size'], 'st_nlink': 1} 113 | else: 114 | raise FuseOSError(ENOENT) 115 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 116 | 117 | def open(self, path, flags): 118 | self.fd += 1 119 | return self.fd 120 | 121 | @_c.ensure_lower_path 122 | def readdir(self, path, fh): 123 | yield from ('.', '..') 124 | yield from (x[1:] for x in self.files) 125 | 126 | @_c.ensure_lower_path 127 | def read(self, path, size, offset, fh): 128 | fi = self.files[path] 129 | 130 | if offset > fi['size']: 131 | return b'' 132 | 133 | data = bytearray() 134 | 135 | block = fi['start'] 136 | while True: 137 | self.f.seek(block * 0x4000) 138 | data.extend(self.f.read(0x4000)) 139 | block = self.fat_entries[block] 140 | if block == -1: 141 | break 142 | if block in [0, -2, -3]: 143 | return b'' 144 | 145 | if len(data) != fi['size']: 146 | return b'' 147 | 148 | if offset + size > fi['size']: 149 | size = fi['size'] - offset 150 | 151 | return bytes(data[offset:offset + size]) 152 | 153 | @_c.ensure_lower_path 154 | def statfs(self, path): 155 | return {'f_bsize': 0x4000, 'f_frsize': 0x4000, 'f_blocks': 0xFF0 - 0x40, 'f_bavail': 0xFF0 - 0x40 - self.used, 156 | 'f_bfree': 0xFF0 - 0x40 - self.used, 'f_files': len(self.files)} 157 | 158 | @_c.ensure_lower_path 159 | def write(self, path, data, offset, fh): 160 | raise FuseOSError(EROFS) 161 | 162 | 163 | def main(prog: str = None, args: list = None): 164 | from argparse import ArgumentParser 165 | if args is None: 166 | args = argv[1:] 167 | parser = ArgumentParser(prog=prog, description='Mount iQue Player NAND images.', 168 | parents=(_c.default_argp, _c.main_args('nand', 'iQue Player NAND image'))) 169 | 170 | a = parser.parse_args(args) 171 | opts = dict(_c.parse_fuse_opts(a.o)) 172 | 173 | if a.do: 174 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 175 | 176 | nand_stat = get_time(a.nand) 177 | 178 | with open(a.nand, 'rb') as f: 179 | mount = BBNandImageMount(nand_fp=f, g_stat=nand_stat) 180 | if _c.macos or _c.windows: 181 | opts['fstypename'] = 'BBFS' 182 | if _c.macos: 183 | opts['volname'] = f'iQue Player NAND ({basename(a.nand)})' 184 | elif _c.windows: 185 | # volume label can only be up to 32 chars 186 | opts['volname'] = 'iQue Player NAND' 187 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 188 | fsname=realpath(a.nand).replace(',', '_'), **opts) 189 | -------------------------------------------------------------------------------- /ninfs/mount/nandtwl.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts Nintendo DSi NAND images, creating a virtual filesystem of decrypted partitions. 9 | """ 10 | 11 | import logging 12 | import os 13 | from errno import ENOENT, EROFS 14 | from hashlib import sha1 15 | from stat import S_IFDIR, S_IFREG 16 | from struct import pack 17 | from sys import exit, argv 18 | from typing import BinaryIO 19 | 20 | from pyctr.crypto import CryptoEngine, Keyslot 21 | from pyctr.util import readbe, readle 22 | 23 | from . import _common as _c 24 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 25 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath, basename 26 | 27 | 28 | class TWLNandImageMount(LoggingMixIn, Operations): 29 | fd = 0 30 | 31 | def __init__(self, nand_fp: BinaryIO, g_stat: dict, consoleid: str = None, cid: str = None, 32 | readonly: bool = False): 33 | self.crypto = CryptoEngine(setup_b9_keys=False) 34 | self.readonly = readonly 35 | 36 | self.g_stat = g_stat 37 | 38 | self.files = {} 39 | 40 | self.f = nand_fp 41 | 42 | nand_size = nand_fp.seek(0, 2) 43 | if nand_size < 0xF000000: 44 | exit(f'NAND is too small (expected >= 0xF000000, got {nand_size:#X}') 45 | if nand_size & 0x40 == 0x40: 46 | self.files['/nocash_blk.bin'] = {'offset': nand_size - 0x40, 'size': 0x40, 'type': 'dec'} 47 | 48 | nand_fp.seek(0) 49 | 50 | try: 51 | consoleid = bytes.fromhex(consoleid) 52 | except (ValueError, TypeError): 53 | try: 54 | with open(consoleid, 'rb') as f: 55 | consoleid = f.read(0x10) 56 | except (FileNotFoundError, TypeError): 57 | # read Console ID and CID from footer 58 | try: 59 | nocash_blk: bytes = self.read('/nocash_blk.bin', 0x40, 0, 0) 60 | except KeyError: 61 | if consoleid is None: 62 | exit('Nocash block not found, and Console ID not provided.') 63 | else: 64 | exit('Failed to convert Console ID to bytes, or file did not exist.') 65 | else: 66 | if len(nocash_blk) != 0x40: 67 | exit('Failed to read 0x40 of footer (this should never happen)') 68 | if nocash_blk[0:0x10] != b'DSi eMMC CID/CPU': 69 | exit('Failed to find footer magic "DSi eMMC CID/CPU"') 70 | if len(set(nocash_blk[0x10:0x40])) == 1: 71 | exit('Nocash block is entirely empty. Maybe re-dump NAND with another exploit, or manually ' 72 | 'get Console ID with some other method.') 73 | cid = nocash_blk[0x10:0x20] 74 | consoleid = nocash_blk[0x20:0x28][::-1] 75 | print('Console ID and CID read from nocash block.') 76 | 77 | twl_consoleid_list = (readbe(consoleid[4:8]), readbe(consoleid[0:4])) 78 | 79 | key_x_list = [twl_consoleid_list[0], 80 | twl_consoleid_list[0] ^ 0x24EE6906, 81 | twl_consoleid_list[1] ^ 0xE65B601D, 82 | twl_consoleid_list[1]] 83 | 84 | self.crypto.set_keyslot('x', Keyslot.TWLNAND, pack('<4I', *key_x_list)) 85 | 86 | nand_fp.seek(0) 87 | header_enc = nand_fp.read(0x200) 88 | 89 | if cid: 90 | if not isinstance(cid, bytes): # if cid was already read above 91 | try: 92 | cid = bytes.fromhex(cid) 93 | except ValueError: 94 | try: 95 | with open(cid, 'rb') as f: 96 | cid = f.read(0x10) 97 | except FileNotFoundError: 98 | exit('Failed to convert CID to bytes, or file did not exist.') 99 | self.ctr = readle(sha1(cid).digest()[0:16]) 100 | 101 | else: 102 | # attempt to generate counter 103 | block_0x1c = readbe(header_enc[0x1C0:0x1D0]) 104 | blk_xored = block_0x1c ^ 0x1804060FE03B77080000896F06000002 105 | ctr_offs = self.crypto.create_ecb_cipher(Keyslot.TWLNAND).decrypt(blk_xored.to_bytes(0x10, 'little')) 106 | self.ctr = int.from_bytes(ctr_offs, 'big') - 0x1C 107 | 108 | # try the counter 109 | block_0x1d = header_enc[0x1D0:0x1E0] 110 | out = self.crypto.create_ctr_cipher(Keyslot.TWLNAND, self.ctr + 0x1D).decrypt(block_0x1d) 111 | if out != b'\xce<\x06\x0f\xe0\xbeMx\x06\x00\xb3\x05\x01\x00\x00\x02': 112 | exit('Counter could not be automatically generated. Please provide the CID, ' 113 | 'or ensure the provided Console ID is correct.') 114 | print('Counter automatically generated.') 115 | 116 | self.files['/stage2_infoblk1.bin'] = {'offset': 0x200, 'size': 0x200, 'type': 'dec'} 117 | self.files['/stage2_infoblk2.bin'] = {'offset': 0x400, 'size': 0x200, 'type': 'dec'} 118 | self.files['/stage2_infoblk3.bin'] = {'offset': 0x600, 'size': 0x200, 'type': 'dec'} 119 | self.files['/stage2_bootldr.bin'] = {'offset': 0x800, 'size': 0x4DC00, 'type': 'dec'} 120 | self.files['/stage2_footer.bin'] = {'offset': 0x4E400, 'size': 0x400, 'type': 'dec'} 121 | self.files['/diag_area.bin'] = {'offset': 0xFFA00, 'size': 0x400, 'type': 'dec'} 122 | 123 | header = self.crypto.create_ctr_cipher(Keyslot.TWLNAND, self.ctr).decrypt(header_enc) 124 | mbr = header[0x1BE:0x200] 125 | mbr_sig = mbr[0x40:0x42] 126 | if mbr_sig != b'\x55\xaa': 127 | exit(f'MBR signature not found (expected "55aa", got "{mbr_sig.hex()}"). ' 128 | f'Make sure the provided Console ID and CID are correct.') 129 | partitions = [[readle(mbr[i + 8:i + 12]) * 0x200, 130 | readle(mbr[i + 12:i + 16]) * 0x200] for i in range(0, 0x40, 0x10)] 131 | 132 | for idx, part in enumerate(partitions): 133 | if part[0]: 134 | ptype = 'enc' if idx < 2 else 'dec' 135 | pname = ('twl_main', 'twl_photo', 'twl_unk1', 'twk_unk2')[idx] 136 | self.files[f'/{pname}.img'] = {'offset': part[0], 'size': part[1], 'type': ptype} 137 | 138 | def __del__(self, *args): 139 | try: 140 | self.f.close() 141 | except AttributeError: 142 | pass 143 | 144 | destroy = __del__ 145 | 146 | def flush(self, path, fh): 147 | return self.f.flush() 148 | 149 | @_c.ensure_lower_path 150 | def getattr(self, path, fh=None): 151 | uid, gid, pid = fuse_get_context() 152 | if path == '/': 153 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 154 | elif path in self.files: 155 | st = {'st_mode': (S_IFREG | 0o666), 156 | 'st_size': self.files[path]['size'], 'st_nlink': 1} 157 | else: 158 | raise FuseOSError(ENOENT) 159 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 160 | 161 | def open(self, path, flags): 162 | self.fd += 1 163 | return self.fd 164 | 165 | @_c.ensure_lower_path 166 | def readdir(self, path, fh): 167 | yield from ('.', '..') 168 | yield from (x[1:] for x in self.files) 169 | 170 | @_c.ensure_lower_path 171 | def read(self, path, size, offset, fh): 172 | fi = self.files[path] 173 | real_offset = fi['offset'] + offset 174 | if fi['offset'] + offset > fi['offset'] + fi['size']: 175 | return b'' 176 | if offset + size > fi['size']: 177 | size = fi['size'] - offset 178 | 179 | self.f.seek(real_offset) 180 | data = self.f.read(size) 181 | if fi['type'] == 'enc': 182 | before = offset % 16 183 | after = (offset + size) % 16 184 | data = (b'\0' * before) + data + (b'\0' * after) 185 | iv = self.ctr + (real_offset >> 4) 186 | data = self.crypto.create_ctr_cipher(Keyslot.TWLNAND, iv).decrypt(data)[before:len(data) - after] 187 | 188 | return data 189 | 190 | @_c.ensure_lower_path 191 | def statfs(self, path): 192 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': 0xF000000 // 4096, 'f_bavail': 0, 'f_bfree': 0, 193 | 'f_files': len(self.files)} 194 | 195 | @_c.ensure_lower_path 196 | def write(self, path, data, offset, fh): 197 | if self.readonly: 198 | raise FuseOSError(EROFS) 199 | 200 | fi = self.files[path] 201 | real_offset = fi['offset'] + offset 202 | real_len = len(data) 203 | if offset >= fi['size']: 204 | print('attempt to start writing past file') 205 | return real_len 206 | if real_offset + len(data) > fi['offset'] + fi['size']: 207 | data = data[:-((real_offset + len(data)) - fi['size'])] 208 | 209 | if fi['type'] == 'dec': 210 | self.f.seek(real_offset) 211 | self.f.write(data) 212 | 213 | else: 214 | before = offset % 16 215 | after = 16 - ((offset + real_len) % 16) 216 | if after == 16: 217 | after = 0 218 | iv = self.ctr + (real_offset >> 4) 219 | data = (b'\0' * before) + data + (b'\0' * after) 220 | out_data = self.crypto.create_ctr_cipher(Keyslot.TWLNAND, iv).encrypt(data)[before:real_len - after] 221 | self.f.seek(real_offset) 222 | self.f.write(out_data) 223 | 224 | return real_len 225 | 226 | 227 | def main(prog: str = None, args: list = None): 228 | from argparse import ArgumentParser 229 | if args is None: 230 | args = argv[1:] 231 | parser = ArgumentParser(prog=prog, description='Mount Nintendo DSi NAND images.', 232 | parents=(_c.default_argp, _c.readonly_argp, _c.main_args('nand', 'DSi NAND image'))) 233 | parser.add_argument('--console-id', help='Console ID, as hex or file') 234 | parser.add_argument('--cid', help='EMMC CID, as hex or file. Not required in 99%% of cases.', default=None) 235 | 236 | a = parser.parse_args(args) 237 | opts = dict(_c.parse_fuse_opts(a.o)) 238 | 239 | if a.do: 240 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 241 | 242 | nand_stat = get_time(a.nand) 243 | 244 | with open(a.nand, f'r{"" if a.ro else "+"}b') as f: 245 | mount = TWLNandImageMount(nand_fp=f, g_stat=nand_stat, consoleid=a.console_id, cid=a.cid, readonly=a.ro) 246 | if _c.macos or _c.windows: 247 | opts['fstypename'] = 'TWLFS' 248 | if _c.macos: 249 | opts['volname'] = f'Nintendo DSi NAND ({basename(a.nand)})' 250 | elif _c.windows: 251 | # volume label can only be up to 32 chars 252 | opts['volname'] = 'Nintendo DSi NAND' 253 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=a.ro, nothreads=True, debug=a.d, 254 | fsname=realpath(a.nand).replace(',', '_'), **opts) 255 | -------------------------------------------------------------------------------- /ninfs/mount/ncch.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts NCCH containers, creating a virtual filesystem of decrypted sections. 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from stat import S_IFDIR, S_IFREG 14 | from sys import argv 15 | from typing import TYPE_CHECKING 16 | 17 | from pyctr.crypto import load_seeddb 18 | from pyctr.type.ncch import NCCHReader, NCCHSection 19 | 20 | from . import _common as _c 21 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 22 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \ 23 | realpath 24 | from .exefs import ExeFSMount 25 | from .romfs import RomFSMount 26 | 27 | if TYPE_CHECKING: 28 | from typing import Dict 29 | 30 | 31 | class NCCHContainerMount(LoggingMixIn, Operations): 32 | fd = 0 33 | romfs_fuse = None 34 | exefs_fuse = None 35 | 36 | def __init__(self, reader: 'NCCHReader', g_stat: dict): 37 | self.files: Dict[str, NCCHSection] = {} 38 | 39 | # get status change, modify, and file access times 40 | self.g_stat = g_stat 41 | 42 | self.reader = reader 43 | 44 | def __del__(self, *args): 45 | try: 46 | self.reader.close() 47 | except AttributeError: 48 | pass 49 | 50 | destroy = __del__ 51 | 52 | def init(self, path, _setup_romfs=True): 53 | decrypted_filename = '/decrypted.' + ('cxi' if self.reader.flags.executable else 'cfa') 54 | 55 | self.files[decrypted_filename] = NCCHSection.FullDecrypted 56 | self.files['/ncch.bin'] = NCCHSection.Header 57 | 58 | if NCCHSection.ExtendedHeader in self.reader.sections: 59 | self.files['/extheader.bin'] = NCCHSection.ExtendedHeader 60 | 61 | if NCCHSection.Logo in self.reader.sections: 62 | self.files['/logo.bin'] = NCCHSection.Logo 63 | 64 | if NCCHSection.Plain in self.reader.sections: 65 | self.files['/plain.bin'] = NCCHSection.Plain 66 | 67 | if NCCHSection.ExeFS in self.reader.sections: 68 | self.files['/exefs.bin'] = NCCHSection.ExeFS 69 | self.exefs_fuse = ExeFSMount(self.reader.exefs, g_stat=self.g_stat, decompress_code=True) 70 | self.exefs_fuse.init(path) 71 | 72 | if NCCHSection.RomFS in self.reader.sections: 73 | self.files['/romfs.bin'] = NCCHSection.RomFS 74 | self.romfs_fuse = RomFSMount(self.reader.romfs, g_stat=self.g_stat) 75 | 76 | @_c.ensure_lower_path 77 | def getattr(self, path, fh=None): 78 | if path.startswith('/exefs/'): 79 | return self.exefs_fuse.getattr(_c.remove_first_dir(path), fh) 80 | elif path.startswith('/romfs/'): 81 | return self.romfs_fuse.getattr(_c.remove_first_dir(path), fh) 82 | uid, gid, pid = fuse_get_context() 83 | if path in {'/', '/romfs', '/exefs'}: 84 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 85 | elif path in self.files: 86 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.reader.sections[self.files[path]].size, 'st_nlink': 1} 87 | else: 88 | raise FuseOSError(ENOENT) 89 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 90 | 91 | def open(self, path, flags): 92 | self.fd += 1 93 | return self.fd 94 | 95 | @_c.ensure_lower_path 96 | def readdir(self, path, fh): 97 | if path.startswith('/exefs'): 98 | yield from self.exefs_fuse.readdir(_c.remove_first_dir(path), fh) 99 | elif path.startswith('/romfs'): 100 | yield from self.romfs_fuse.readdir(_c.remove_first_dir(path), fh) 101 | elif path == '/': 102 | yield from ('.', '..') 103 | yield from (x[1:] for x in self.files) 104 | if self.exefs_fuse is not None: 105 | yield 'exefs' 106 | if self.romfs_fuse is not None: 107 | yield 'romfs' 108 | 109 | @_c.ensure_lower_path 110 | def read(self, path, size, offset, fh): 111 | if path.startswith('/exefs/'): 112 | return self.exefs_fuse.read(_c.remove_first_dir(path), size, offset, fh) 113 | elif path.startswith('/romfs/'): 114 | return self.romfs_fuse.read(_c.remove_first_dir(path), size, offset, fh) 115 | 116 | section = self.files[path] 117 | with self.reader.open_raw_section(section) as f: 118 | f.seek(offset) 119 | return f.read(size) 120 | 121 | @_c.ensure_lower_path 122 | def statfs(self, path): 123 | if path.startswith('/exefs/'): 124 | return self.exefs_fuse.statfs(_c.remove_first_dir(path)) 125 | elif path.startswith('/romfs/'): 126 | return self.romfs_fuse.statfs(_c.remove_first_dir(path)) 127 | else: 128 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.reader.content_size // 4096, 'f_bavail': 0, 129 | 'f_bfree': 0, 'f_files': len(self.files)} 130 | 131 | 132 | def main(prog: str = None, args: list = None): 133 | from argparse import ArgumentParser 134 | if args is None: 135 | args = argv[1:] 136 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS NCCH containers.', 137 | parents=(_c.default_argp, _c.ctrcrypto_argp, _c.seeddb_argp, 138 | _c.main_args('ncch', 'NCCH file'))) 139 | parser.add_argument('--dec', help='assume contents are decrypted', action='store_true') 140 | 141 | a = parser.parse_args(args) 142 | opts = dict(_c.parse_fuse_opts(a.o)) 143 | 144 | if a.do: 145 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 146 | 147 | ncch_stat = get_time(a.ncch) 148 | 149 | load_custom_boot9(a.boot9) 150 | 151 | if a.seeddb: 152 | load_seeddb(a.seeddb) 153 | 154 | with NCCHReader(a.ncch, dev=a.dev, assume_decrypted=a.dec, seed=a.seed) as r: 155 | mount = NCCHContainerMount(reader=r, g_stat=ncch_stat) 156 | if _c.macos or _c.windows: 157 | opts['fstypename'] = 'NCCH' 158 | if _c.macos: 159 | display = f'{r.partition_id.upper()}; {r.product_code}' 160 | try: 161 | title = r.exefs.icon.get_app_title() 162 | if title.short_desc != 'unknown': 163 | display += '; ' + title.short_desc 164 | except: 165 | pass 166 | opts['volname'] = f'NCCH Container ({display})' 167 | elif _c.windows: 168 | # volume label can only be up to 32 chars 169 | try: 170 | title = r.exefs.icon.get_app_title().short_desc 171 | if len(title) > 26: 172 | title = title[0:25] + '\u2026' # ellipsis 173 | display = title 174 | except: 175 | display = r.partition_id.upper() 176 | opts['volname'] = f'NCCH ({display})' 177 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 178 | fsname=realpath(a.ncch).replace(',', '_'), **opts) 179 | -------------------------------------------------------------------------------- /ninfs/mount/romfs.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts Read-only Filesystem (RomFS) files, creating a virtual filesystem of the RomFS contents. Accepts ones with and 9 | without an IVFC header (original HANS format). 10 | """ 11 | 12 | import logging 13 | from errno import ENOENT 14 | from stat import S_IFDIR, S_IFREG 15 | from sys import argv 16 | 17 | from pyctr.type.romfs import RomFSReader, RomFSFileNotFoundError 18 | 19 | from . import _common as _c 20 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 21 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath, basename 22 | 23 | 24 | class RomFSMount(LoggingMixIn, Operations): 25 | fd = 0 26 | 27 | def __init__(self, reader: 'RomFSReader', g_stat: dict): 28 | # get status change, modify, and file access times 29 | self.g_stat = g_stat 30 | 31 | self.reader = reader 32 | 33 | def __del__(self, *args): 34 | try: 35 | self.reader.close() 36 | except AttributeError: 37 | pass 38 | 39 | destroy = __del__ 40 | 41 | def getattr(self, path, fh=None): 42 | uid, gid, pid = fuse_get_context() 43 | try: 44 | item = self.reader.get_info_from_path(path) 45 | except RomFSFileNotFoundError: 46 | raise FuseOSError(ENOENT) 47 | if item.type == 'dir': 48 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 49 | elif item.type == 'file': 50 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': item.size, 'st_nlink': 1} 51 | else: 52 | # this won't happen unless I fucked up 53 | raise FuseOSError(ENOENT) 54 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 55 | 56 | def open(self, path, flags): 57 | self.fd += 1 58 | return self.fd 59 | 60 | def readdir(self, path, fh): 61 | try: 62 | item = self.reader.get_info_from_path(path) 63 | except RomFSFileNotFoundError: 64 | raise FuseOSError(ENOENT) 65 | yield from ('.', '..') 66 | yield from item.contents 67 | 68 | def read(self, path, size, offset, fh): 69 | try: 70 | with self.reader.open(path) as f: 71 | f.seek(offset) 72 | return f.read(size) 73 | except (KeyError, RomFSFileNotFoundError): 74 | raise FuseOSError(ENOENT) 75 | 76 | def statfs(self, path): 77 | try: 78 | item = self.reader.get_info_from_path(path) 79 | except RomFSFileNotFoundError: 80 | raise FuseOSError(ENOENT) 81 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.reader.total_size // 4096, 'f_bavail': 0, 82 | 'f_bfree': 0, 'f_files': len(item.contents)} 83 | 84 | 85 | def main(prog: str = None, args: list = None): 86 | from argparse import ArgumentParser 87 | if args is None: 88 | args = argv[1:] 89 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS Read-only Filesystem (RomFS) files.', 90 | parents=(_c.default_argp, _c.main_args('romfs', 'RomFS file'))) 91 | 92 | a = parser.parse_args(args) 93 | opts = dict(_c.parse_fuse_opts(a.o)) 94 | 95 | if a.do: 96 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 97 | 98 | romfs_stat = get_time(a.romfs) 99 | 100 | with RomFSReader(a.romfs, case_insensitive=True) as r: 101 | mount = RomFSMount(reader=r, g_stat=romfs_stat) 102 | if _c.macos or _c.windows: 103 | opts['fstypename'] = 'RomFS' 104 | if _c.macos: 105 | opts['volname'] = f'Nintendo 3DS RomFS ({basename(a.romfs)})' 106 | elif _c.windows: 107 | # volume label can only be up to 32 chars 108 | opts['volname'] = 'Nintendo 3DS RomFS' 109 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 110 | fsname=realpath(a.romfs).replace(',', '_'), **opts) 111 | -------------------------------------------------------------------------------- /ninfs/mount/sd.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts SD contents under `/Nintendo 3DS`, creating a virtual filesystem with decrypted contents. movable.sed required. 9 | """ 10 | 11 | import logging 12 | import os 13 | from errno import EPERM, EACCES 14 | from os.path import basename, dirname, isdir 15 | from sys import exit, argv 16 | from threading import Lock 17 | from typing import TYPE_CHECKING 18 | 19 | from pyctr.crypto import CryptoEngine, Keyslot 20 | 21 | from . import _common as _c 22 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 23 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, realpath 24 | 25 | if _c.windows: 26 | from ctypes import c_wchar_p, pointer, c_ulonglong, windll, wintypes 27 | 28 | if TYPE_CHECKING: 29 | from typing import BinaryIO, Dict, Optional, Tuple 30 | 31 | 32 | class SDFilesystemMount(LoggingMixIn, Operations): 33 | 34 | @_c.ensure_lower_path 35 | def path_to_iv(self, path): 36 | return CryptoEngine.sd_path_to_iv(path[self.root_len + 33:]) 37 | 38 | def fd_to_fileobj(self, path, mode, fd): 39 | fh = open(fd, mode, buffering=0) 40 | lock = Lock() 41 | if not (basename(path).startswith('.') or 'nintendo dsiware' in path.lower() or dirname(path) == self.root): 42 | fh_enc = self.crypto.create_ctr_io(Keyslot.SD, fh, self.path_to_iv(path)) 43 | fh_group = (fh_enc, fh, lock) 44 | else: 45 | fh_group = (fh, None, lock) 46 | self.fds[fd] = fh_group 47 | return fd 48 | 49 | def __init__(self, sd_dir: str, movable: bytes, dev: bool = False, readonly: bool = False, boot9: str = None): 50 | self.crypto = CryptoEngine(boot9=boot9, dev=dev) 51 | 52 | # only allows one create/open/release operation at a time 53 | self.global_lock = Lock() 54 | 55 | # each fd contains a tuple with a file object, a base file object (for encrypted files), 56 | # and a thread lock to prevent two read or write operations from screwing with eachother 57 | self.fds: 'Dict[int, Tuple[BinaryIO, Optional[BinaryIO], Lock]]' = {} 58 | 59 | self.crypto.setup_sd_key(movable) 60 | self.root_dir = self.crypto.id0.hex() 61 | 62 | if not isdir(sd_dir + '/' + self.root_dir): 63 | exit(f'Could not find ID0 {self.root_dir} in the SD directory.') 64 | 65 | print('ID0:', self.root_dir) 66 | print('Key:', self.crypto.keygen(Keyslot.SD).hex()) 67 | 68 | self.root = realpath(sd_dir + '/' + self.root_dir) 69 | self.root_len = len(self.root) 70 | 71 | self.readonly = readonly 72 | 73 | # noinspection PyMethodOverriding 74 | def __call__(self, op, path, *args): 75 | return super().__call__(op, self.root + path, *args) 76 | 77 | def access(self, path, mode): 78 | if not os.access(path, mode): 79 | raise FuseOSError(EACCES) 80 | 81 | @_c.raise_on_readonly 82 | def chmod(self, path, mode): 83 | os.chmod(path, mode) 84 | 85 | @_c.raise_on_readonly 86 | def chown(self, path, *args, **kwargs): 87 | if not _c.windows: 88 | os.chown(path, *args, **kwargs) 89 | 90 | @_c.raise_on_readonly 91 | def create(self, path, mode, fi=None): 92 | # prevent another create/open/release from interfering 93 | with self.global_lock: 94 | fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode) 95 | return self.fd_to_fileobj(path, 'wb+', fd) 96 | 97 | @_c.raise_on_readonly 98 | def flush(self, path, fh): 99 | fd, _, lock = self.fds[fh] 100 | with lock: 101 | fd.flush() 102 | 103 | def getattr(self, path, fh=None): 104 | st = os.lstat(path) 105 | uid, gid, _ = fuse_get_context() 106 | res = {key: getattr(st, key) for key in ('st_atime', 'st_ctime', 'st_mode', 'st_mtime', 'st_nlink', 'st_size', 107 | 'st_flags') if hasattr(st, key)} 108 | res['st_uid'] = st.st_uid if st.st_uid != 0 else uid 109 | res['st_gid'] = st.st_gid if st.st_gid != 0 else gid 110 | return res 111 | 112 | def link(self, target, source): 113 | return os.link(source, target) 114 | 115 | @_c.raise_on_readonly 116 | def mkdir(self, path, mode): 117 | os.mkdir(path, mode) 118 | 119 | @_c.raise_on_readonly 120 | def mknod(self, path, mode, dev): 121 | if not _c.windows: 122 | os.mknod(path, mode, dev) 123 | 124 | # open = os.open 125 | def open(self, path, flags): 126 | # prevent another create/open/release from interfering 127 | with self.global_lock: 128 | fd = os.open(path, flags) 129 | return self.fd_to_fileobj(path, 'rb+', fd) 130 | 131 | def read(self, path, size, offset, fh): 132 | fd, _, lock = self.fds[fh] 133 | 134 | # acquire lock to prevent another read/write from messing with this operation 135 | with lock: 136 | fd.seek(offset) 137 | return fd.read(size) 138 | 139 | def readdir(self, path, fh): 140 | yield from ('.', '..') 141 | 142 | # due to DSiWare exports having unique crypto that is a pain to handle, this hides it to prevent misleading 143 | # users into thinking that the files are decrypted. 144 | ld = (d for d in os.listdir(path) if not d.lower() == 'nintendo dsiware') 145 | 146 | if _c.windows: 147 | # I should figure out how to mark hidden files, if possible 148 | yield from (d for d in ld if not d.startswith('.')) 149 | else: 150 | yield from ld 151 | 152 | readlink = os.readlink 153 | 154 | def release(self, path, fh): 155 | # prevent another create/open/release from interfering 156 | with self.global_lock: 157 | fd_group = self.fds[fh] 158 | # prevent use of the handle while cleaning up, or closing while in use 159 | with fd_group[2]: 160 | fd_group[0].close() 161 | try: 162 | fd_group[1].close() 163 | except AttributeError: 164 | # unencrypted files have the second item set to None 165 | pass 166 | del self.fds[fh] 167 | 168 | @_c.raise_on_readonly 169 | def rename(self, old, new): 170 | # renaming's too difficult. just copy the file to the name you want if you really need it. 171 | raise FuseOSError(EPERM) 172 | 173 | @_c.raise_on_readonly 174 | def rmdir(self, path): 175 | os.rmdir(path) 176 | 177 | # noinspection PyPep8Naming 178 | def statfs(self, path): 179 | if _c.windows: 180 | lpSectorsPerCluster = c_ulonglong(0) 181 | lpBytesPerSector = c_ulonglong(0) 182 | lpNumberOfFreeClusters = c_ulonglong(0) 183 | lpTotalNumberOfClusters = c_ulonglong(0) 184 | ret = windll.kernel32.GetDiskFreeSpaceW(c_wchar_p(path), pointer(lpSectorsPerCluster), 185 | pointer(lpBytesPerSector), 186 | pointer(lpNumberOfFreeClusters), 187 | pointer(lpTotalNumberOfClusters)) 188 | if not ret: 189 | raise WindowsError 190 | free_blocks = lpNumberOfFreeClusters.value * lpSectorsPerCluster.value 191 | result = {'f_bavail': free_blocks, 192 | 'f_bfree': free_blocks, 193 | 'f_bsize': lpBytesPerSector.value, 194 | 'f_frsize': lpBytesPerSector.value, 195 | 'f_blocks': lpTotalNumberOfClusters.value * lpSectorsPerCluster.value, 196 | 'f_namemax': wintypes.MAX_PATH} 197 | return result 198 | else: 199 | stv = os.statvfs(path) 200 | # f_flag causes python interpreter crashes in some cases. i don't get it. 201 | return {key: getattr(stv, key) for key in ('f_bavail', 'f_bfree', 'f_blocks', 'f_bsize', 'f_favail', 202 | 'f_ffree', 'f_files', 'f_frsize', 'f_namemax')} 203 | 204 | def symlink(self, target, source): 205 | return os.symlink(source, target) 206 | 207 | def truncate(self, path, length, fh=None): 208 | try: 209 | fd, _, lock = self.fds[fh] 210 | # acquire lock to prevent another read/write from messing with this operation 211 | with lock: 212 | fd.truncate(length) 213 | 214 | except KeyError: # in case this is not an already open file 215 | with open(path, 'rb+') as f: 216 | f.truncate(length) 217 | 218 | @_c.raise_on_readonly 219 | def unlink(self, path): 220 | os.unlink(path) 221 | 222 | @_c.raise_on_readonly 223 | def utimens(self, path, *args, **kwargs): 224 | os.utime(path, *args, **kwargs) 225 | 226 | @_c.raise_on_readonly 227 | def write(self, path, data, offset, fh): 228 | fd, _, lock = self.fds[fh] 229 | 230 | # acquire lock to prevent another read/write from messing with this operation 231 | with lock: 232 | fd.seek(offset) 233 | return fd.write(data) 234 | 235 | 236 | def main(prog: str = None, args: list = None): 237 | from argparse import ArgumentParser 238 | if args is None: 239 | args = argv[1:] 240 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS SD card contents.', 241 | parents=(_c.default_argp, _c.readonly_argp, _c.ctrcrypto_argp, 242 | _c.main_args( 243 | 'sd_dir', "path to folder with SD contents (on SD: /Nintendo 3DS)"))) 244 | group = parser.add_mutually_exclusive_group(required=True) 245 | group.add_argument('--movable', metavar='MOVABLESED', help='path to movable.sed') 246 | group.add_argument('--sd-key', metavar='SDKEY', help='SD key as hexstring') 247 | 248 | a = parser.parse_args(args) 249 | opts = dict(_c.parse_fuse_opts(a.o)) 250 | 251 | if a.do: 252 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 253 | 254 | if a.movable: 255 | with open(a.movable, 'rb') as f: 256 | movable = f.read(0x140) 257 | else: 258 | movable = bytes.fromhex(a.sd_key) 259 | 260 | mount = SDFilesystemMount(sd_dir=a.sd_dir, movable=movable, dev=a.dev, readonly=a.ro, boot9=a.boot9) 261 | if _c.macos or _c.windows: 262 | opts['fstypename'] = 'SDCard' 263 | if _c.macos: 264 | opts['volname'] = f'Nintendo 3DS SD Card ({mount.root_dir})' 265 | # this fixes an issue with creating files through Finder 266 | # a better fix would probably be to support xattrs properly, but given the kind of data and filesystem 267 | # that this code would interact with, it's not really useful. 268 | opts['noappledouble'] = True 269 | else: 270 | # windows 271 | opts['volname'] = f'Nintendo 3DS SD Card ({mount.root_dir[0:8]}…)' 272 | opts['case_insensitive'] = False 273 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=a.ro, debug=a.d, 274 | fsname=realpath(a.sd_dir).replace(',', '_'), **opts) 275 | -------------------------------------------------------------------------------- /ninfs/mount/sdtitle.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts installed SD title contents, creating a virtual filesystem of decrypted contents (if encrypted). 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from glob import glob 14 | from os.path import isfile, join 15 | from stat import S_IFDIR, S_IFREG 16 | from sys import argv 17 | from typing import TYPE_CHECKING 18 | 19 | from pyctr.crypto import load_seeddb 20 | from pyctr.type.sdtitle import SDTitleReader, SDTitleSection 21 | 22 | from . import _common as _c 23 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 24 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, load_custom_boot9, \ 25 | realpath 26 | from .ncch import NCCHContainerMount 27 | from .srl import SRLMount 28 | 29 | if TYPE_CHECKING: 30 | from os import DirEntry 31 | from typing import Dict, Tuple, Union 32 | 33 | 34 | class SDTitleContentsMount(LoggingMixIn, Operations): 35 | fd = 0 36 | total_size = 0 37 | 38 | def __init__(self, reader: 'SDTitleReader', g_stat: dict): 39 | self.dirs: Dict[str, Union[NCCHContainerMount, SRLMount]] = {} 40 | self.files: Dict[str, Tuple[Union[int, SDTitleSection], int, int]] = {} 41 | 42 | # get status change, modify, and file access times 43 | self.g_stat = g_stat 44 | 45 | self.reader = reader 46 | 47 | def __del__(self, *args): 48 | try: 49 | self.reader.close() 50 | except AttributeError: 51 | pass 52 | 53 | destroy = __del__ 54 | 55 | def init(self, path): 56 | def add_file(name: str, section: 'Union[SDTitleSection, int]', added_offset: int = 0): 57 | # added offset is used for a few things like meta icon and tmdchunks 58 | if section >= 0: 59 | size = self.reader.content_info[section].size 60 | else: 61 | with self.reader.open_raw_section(section) as f: 62 | size = f.seek(0, 2) 63 | self.files[name] = (section, added_offset, size - added_offset) 64 | 65 | add_file('/tmd.bin', SDTitleSection.TitleMetadata) 66 | add_file('/tmdchunks.bin', SDTitleSection.TitleMetadata, 0xB04) 67 | 68 | for record in self.reader.content_info: 69 | dirname = f'/{record.cindex:04x}.{record.id}' 70 | is_srl = record.cindex == 0 and self.reader.tmd.title_id[3:5] == '48' 71 | file_ext = 'nds' if is_srl else 'ncch' 72 | filename = f'{dirname}.{file_ext}' 73 | add_file(filename, record.cindex) 74 | try: 75 | if is_srl: 76 | # special case for SRL contents 77 | srl_fp = self.reader.open_raw_section(record.cindex) 78 | self.dirs[dirname] = SRLMount(srl_fp, g_stat=self.g_stat) 79 | else: 80 | mount = NCCHContainerMount(self.reader.contents[record.cindex], g_stat=self.g_stat) 81 | mount.init(path) 82 | self.dirs[dirname] = mount 83 | except Exception as e: 84 | print(f'Failed to mount {filename}: {type(e).__name__}: {e}') 85 | 86 | self.total_size += record.size 87 | 88 | @_c.ensure_lower_path 89 | def getattr(self, path, fh=None): 90 | first_dir = _c.get_first_dir(path) 91 | if first_dir in self.dirs: 92 | return self.dirs[first_dir].getattr(_c.remove_first_dir(path), fh) 93 | uid, gid, pid = fuse_get_context() 94 | if path == '/' or path in self.dirs: 95 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 96 | elif path in self.files: 97 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.files[path][2], 'st_nlink': 1} 98 | else: 99 | raise FuseOSError(ENOENT) 100 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 101 | 102 | def open(self, path, flags): 103 | self.fd += 1 104 | return self.fd 105 | 106 | @_c.ensure_lower_path 107 | def readdir(self, path, fh): 108 | first_dir = _c.get_first_dir(path) 109 | if first_dir in self.dirs: 110 | yield from self.dirs[first_dir].readdir(_c.remove_first_dir(path), fh) 111 | else: 112 | yield from ('.', '..') 113 | yield from (x[1:] for x in self.files) 114 | yield from (x[1:] for x in self.dirs) 115 | 116 | @_c.ensure_lower_path 117 | def read(self, path, size, offset, fh): 118 | first_dir = _c.get_first_dir(path) 119 | if first_dir in self.dirs: 120 | return self.dirs[first_dir].read(_c.remove_first_dir(path), size, offset, fh) 121 | 122 | section = self.files[path] 123 | with self.reader.open_raw_section(section[0]) as f: 124 | f.seek(offset + section[1]) 125 | return f.read(size) 126 | 127 | @_c.ensure_lower_path 128 | def statfs(self, path): 129 | first_dir = _c.get_first_dir(path) 130 | if first_dir in self.dirs: 131 | return self.dirs[first_dir].statfs(_c.remove_first_dir(path)) 132 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.total_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 133 | 'f_files': len(self.files)} 134 | 135 | 136 | def main(prog: str = None, args: list = None): 137 | from argparse import ArgumentParser 138 | if args is None: 139 | args = argv[1:] 140 | parser = ArgumentParser(prog=prog, description='Mount Nintendo 3DS installed SD title contents.', 141 | parents=(_c.default_argp, _c.ctrcrypto_argp, _c.seeddb_argp, 142 | _c.main_args('content', 'tmd file or directory with SD title contents'))) 143 | 144 | a = parser.parse_args(args) 145 | opts = dict(_c.parse_fuse_opts(a.o)) 146 | 147 | if a.do: 148 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 149 | 150 | if isfile(a.content): 151 | tmd_file = a.content 152 | else: 153 | tmd_file = None 154 | tmds = glob(join(a.content, '*.tmd')) 155 | if tmds: 156 | # if there end up being multiple, this should use the first one that's found, which is probably the 157 | # active one used by the system right now 158 | tmd_file = tmds[0] 159 | else: 160 | exit(f'Could not find a tmd in {a.content}') 161 | 162 | sdtitle_stat = get_time(a.content) 163 | 164 | load_custom_boot9(a.boot9) 165 | 166 | if a.seeddb: 167 | load_seeddb(a.seeddb) 168 | 169 | with SDTitleReader(tmd_file, dev=a.dev, seed=a.seed, case_insensitive=True) as r: 170 | mount = SDTitleContentsMount(reader=r, g_stat=sdtitle_stat) 171 | if _c.macos or _c.windows: 172 | opts['fstypename'] = 'SDT' 173 | if _c.macos: 174 | display = r.tmd.title_id.upper() 175 | try: 176 | title = r.contents[0].exefs.icon.get_app_title() 177 | display += f'; ' + r.contents[0].product_code 178 | if title.short_desc != 'unknown': 179 | display += '; ' + title.short_desc 180 | except: 181 | pass 182 | opts['volname'] = f'SD Title Contents ({display})' 183 | elif _c.windows: 184 | # volume label can only be up to 32 chars 185 | try: 186 | title = r.contents[0].exefs.icon.get_app_title().short_desc 187 | if len(title) > 21: 188 | title = title[0:20] + '\u2026' # ellipsis 189 | display = title 190 | except: 191 | display = r.tmd.title_id.upper() 192 | opts['volname'] = f'SD Title ({display})' 193 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 194 | fsname=realpath(tmd_file).replace(',', '_'), **opts) 195 | -------------------------------------------------------------------------------- /ninfs/mount/threedsx.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | """ 8 | Mounts 3DSX Homebrew files, creating a virtual filesystem with the 3DSX's RomFS and SMDH. 9 | """ 10 | 11 | import logging 12 | from errno import ENOENT 13 | from stat import S_IFDIR, S_IFREG 14 | from struct import unpack 15 | from sys import exit, argv 16 | from typing import TYPE_CHECKING, BinaryIO 17 | 18 | from pyctr.type.romfs import RomFSReader 19 | from pyctr.util import readle 20 | 21 | from . import _common as _c 22 | # _common imports these from fusepy, and prints an error if it fails; this allows less duplicated code 23 | from ._common import FUSE, FuseOSError, Operations, LoggingMixIn, fuse_get_context, get_time, realpath, basename 24 | from .romfs import RomFSMount 25 | 26 | if TYPE_CHECKING: 27 | from typing import Dict 28 | 29 | 30 | class ThreeDSXMount(LoggingMixIn, Operations): 31 | fd = 0 32 | 33 | def __init__(self, threedsx_fp: BinaryIO, g_stat: dict): 34 | self.g_stat = g_stat 35 | self.romfs_fuse: RomFSMount = None 36 | 37 | self.f = threedsx_fp 38 | threedsx_fp.seek(0, 2) 39 | self.total_size = threedsx_fp.tell() 40 | threedsx_fp.seek(0) 41 | 42 | header = threedsx_fp.read(0x20) 43 | if readle(header[4:6]) < 44: 44 | exit('3DSX has no SMDH or RomFS.') 45 | 46 | smdh_offset, smdh_size, romfs_offset = unpack('<3I', threedsx_fp.read(12)) # type: int 47 | self.files: Dict[str, Dict[str, int]] = {} 48 | if smdh_offset: # unlikely, you can't add a romfs without this 49 | self.files['/icon.smdh'] = {'size': smdh_size, 'offset': smdh_offset} 50 | if romfs_offset: 51 | self.files['/romfs.bin'] = {'size': self.total_size - romfs_offset, 'offset': romfs_offset} 52 | 53 | def __del__(self, *args): 54 | try: 55 | self.f.close() 56 | except AttributeError: 57 | pass 58 | 59 | destroy = __del__ 60 | 61 | def init(self, path): 62 | if '/romfs.bin' in self.files: 63 | try: 64 | romfs_vfp = _c.VirtualFileWrapper(self, '/romfs.bin', self.files['/romfs.bin']['size']) 65 | romfs_reader = RomFSReader(romfs_vfp) 66 | romfs_fuse = RomFSMount(romfs_reader, self.g_stat) 67 | romfs_fuse.init(path) 68 | self.romfs_fuse = romfs_fuse 69 | except Exception as e: 70 | print(f'Failed to mount RomFS: {type(e).__name__}: {e}') 71 | 72 | def flush(self, path, fh): 73 | return self.f.flush() 74 | 75 | @_c.ensure_lower_path 76 | def getattr(self, path, fh=None): 77 | if path.startswith('/romfs/'): 78 | return self.romfs_fuse.getattr(_c.remove_first_dir(path), fh) 79 | uid, gid, pid = fuse_get_context() 80 | if path == '/' or path == '/romfs': 81 | st = {'st_mode': (S_IFDIR | 0o777), 'st_nlink': 2} 82 | elif path in self.files: 83 | st = {'st_mode': (S_IFREG | 0o666), 'st_size': self.files[path]['size'], 'st_nlink': 1} 84 | else: 85 | raise FuseOSError(ENOENT) 86 | return {**st, **self.g_stat, 'st_uid': uid, 'st_gid': gid} 87 | 88 | def open(self, path, flags): 89 | self.fd += 1 90 | return self.fd 91 | 92 | @_c.ensure_lower_path 93 | def readdir(self, path, fh): 94 | if path.startswith('/romfs'): 95 | yield from self.romfs_fuse.readdir(_c.remove_first_dir(path), fh) 96 | elif path == '/': 97 | yield from ('.', '..') 98 | yield from (x[1:] for x in self.files) 99 | if self.romfs_fuse is not None: 100 | yield 'romfs' 101 | 102 | @_c.ensure_lower_path 103 | def read(self, path, size, offset, fh): 104 | if path.startswith('/romfs/'): 105 | return self.romfs_fuse.read(_c.remove_first_dir(path), size, offset, fh) 106 | 107 | fi = self.files[path] 108 | real_offset = fi['offset'] + offset 109 | if fi['offset'] + offset > fi['offset'] + fi['size']: 110 | return b'' 111 | if offset + size > fi['size']: 112 | size = fi['size'] - offset 113 | self.f.seek(real_offset) 114 | return self.f.read(size) 115 | 116 | @_c.ensure_lower_path 117 | def statfs(self, path): 118 | if path.startswith('/romfs/'): 119 | return self.romfs_fuse.statfs(_c.remove_first_dir(path)) 120 | else: 121 | return {'f_bsize': 4096, 'f_frsize': 4096, 'f_blocks': self.total_size // 4096, 'f_bavail': 0, 'f_bfree': 0, 122 | 'f_files': len(self.files)} 123 | 124 | 125 | def main(prog: str = None, args: list = None): 126 | from argparse import ArgumentParser 127 | if args is None: 128 | args = argv[1:] 129 | parser = ArgumentParser(prog=prog, description='Mount 3DSX Homebrew files.', 130 | parents=(_c.default_argp, _c.main_args('threedsx', '3DSX file'))) 131 | 132 | a = parser.parse_args(args) 133 | opts = dict(_c.parse_fuse_opts(a.o)) 134 | 135 | if a.do: 136 | logging.basicConfig(level=logging.DEBUG, filename=a.do) 137 | 138 | threedsx_stat = get_time(a.threedsx) 139 | 140 | with open(a.threedsx, 'rb') as f: 141 | mount = ThreeDSXMount(threedsx_fp=f, g_stat=threedsx_stat) 142 | if _c.macos or _c.windows: 143 | opts['fstypename'] = '3DSX' 144 | if _c.macos: 145 | opts['volname'] = f'3DSX Homebrew ({basename(a.threedsx)})' 146 | elif _c.windows: 147 | # volume label can only be up to 32 chars 148 | opts['volname'] = '3DSX Homebrew' 149 | FUSE(mount, a.mount_point, foreground=a.fg or a.d, ro=True, nothreads=True, debug=a.d, 150 | fsname=realpath(a.threedsx).replace(',', '_'), **opts) 151 | -------------------------------------------------------------------------------- /ninfs/mountinfo.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | types = { 8 | 'cci': { 9 | 'name': 'CTR Cart Image', 10 | 'info': '".3ds", ".cci"' 11 | }, 12 | 'cdn': { 13 | 'name': 'CDN contents', 14 | 'info': '"cetk", "tmd", and contents' 15 | }, 16 | 'cia': { 17 | 'name': 'CTR Importable Archive', 18 | 'info': '".cia"' 19 | }, 20 | 'exefs': { 21 | 'name': 'Executable Filesystem', 22 | 'info': '".exefs", "exefs.bin"' 23 | }, 24 | 'nandctr': { 25 | 'name': 'Nintendo 3DS NAND backup', 26 | 'info': '"nand.bin"' 27 | }, 28 | 'nandtwl': { 29 | 'name': 'Nintendo DSi NAND backup', 30 | 'info': '"nand_dsi.bin"' 31 | }, 32 | 'nandhac': { 33 | 'name': 'Nintendo Switch NAND backup', 34 | 'info': '"rawnand.bin"' 35 | }, 36 | 'nandbb': { 37 | 'name': 'iQue Player NAND backup', 38 | 'info': '"nand.bin"' 39 | }, 40 | 'ncch': { 41 | 'name': 'NCCH', 42 | 'info': '".cxi", ".cfa", ".ncch", ".app"' 43 | }, 44 | 'romfs': { 45 | 'name': 'Read-only Filesystem', 46 | 'info': '".romfs", "romfs.bin"' 47 | }, 48 | 'sd': { 49 | 'name': 'SD Card Contents', 50 | 'info': '"Nintendo 3DS" from SD' 51 | }, 52 | 'sdtitle': { 53 | 'name': 'Installed SD Title Contents', 54 | 'info': '"*.tmd" and "*.app" files' 55 | }, 56 | 'srl': { 57 | 'name': 'Nintendo DS ROM image', 58 | 'info': '".nds", ".srl"' 59 | }, 60 | 'threedsx': { 61 | 'name': '3DSX Homebrew', 62 | 'info': '".3dsx"' 63 | }, 64 | } 65 | 66 | aliases = { 67 | '3ds': 'cci', 68 | '3dsx': 'threedsx', 69 | 'app': 'ncch', 70 | 'csu': 'cci', 71 | 'cxi': 'ncch', 72 | 'cfa': 'ncch', 73 | 'nand': 'nandctr', 74 | 'nanddsi': 'nandtwl', 75 | 'nandique': 'nandbb', 76 | 'nandswitch': 'nandhac', 77 | 'nandnx': 'nandhac', 78 | 'nds': 'srl', 79 | } 80 | 81 | categories = { 82 | 'Nintendo 3DS': ['cci', 'cdn', 'cia', 'exefs', 'nandctr', 'ncch', 'romfs', 'sd', 'sdtitle', 'threedsx'], 83 | 'Nintendo DS / DSi': ['nandtwl', 'srl'], 84 | 'Nintendo Switch': ['nandhac'], 85 | 'iQue Player': ['nandbb'] 86 | } 87 | 88 | # this will add the "Use developer-unit keys" option to Advanced options in the gui 89 | supports_dev_keys = ['cci', 'cdn', 'cia', 'nandctr', 'ncch', 'sd', 'sdtitle'] 90 | 91 | 92 | def get_type_info(mount_type): 93 | return types[aliases.get(mount_type, mount_type)] 94 | -------------------------------------------------------------------------------- /ninfs/reg_shell.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from sys import executable 8 | from os import remove 9 | from os.path import abspath, dirname 10 | from subprocess import Popen 11 | from tempfile import NamedTemporaryFile 12 | import winreg 13 | 14 | # these do not use winreg due to administrator access being required. 15 | base = r'''Windows Registry Editor Version 5.00 16 | 17 | [HKEY_CLASSES_ROOT\*\shell\Mount with ninfs\command] 18 | @="\"{exec}\" {extra} gui \"%1\"" 19 | 20 | [-HKEY_CLASSES_ROOT\*\shell\Mount with fuse-3ds] 21 | ''' 22 | 23 | base_del = r'''Windows Registry Editor Version 5.00 24 | 25 | [-HKEY_CLASSES_ROOT\*\shell\Mount with ninfs] 26 | 27 | [-HKEY_CLASSES_ROOT\*\shell\Mount with fuse-3ds] 28 | ''' 29 | 30 | 31 | def call_regedit(data: str): 32 | # this does not use winreg due to administrator access being required. 33 | t = NamedTemporaryFile('w', delete=False, encoding='cp1252', suffix='-ninfs.reg') 34 | try: 35 | t.write(data) 36 | t.close() # need to close so regedit can open it 37 | p = Popen(['regedit.exe', t.name], shell=True) 38 | p.wait() 39 | finally: 40 | t.close() # just in case 41 | remove(t.name) 42 | 43 | 44 | def add_reg(_pyi: bool): 45 | fmtmap = {'exec': executable.replace('\\', '\\\\'), 'extra': ''} 46 | if not _pyi: 47 | fmtmap['extra'] = r'\"{}\"'.format(dirname(abspath(__file__)).replace('\\', '\\\\')) 48 | call_regedit(base.format_map(fmtmap)) 49 | 50 | 51 | def del_reg(): 52 | call_regedit(base_del) 53 | 54 | 55 | # I'm wondering how I could properly check if this is running as admin, instead of always checking if UAC is disabled 56 | # if IsUserAnAdmin is 1. 57 | def uac_enabled() -> bool: 58 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System', 0, 59 | winreg.KEY_READ) 60 | return bool(winreg.QueryValueEx(key, 'EnableLUA')[0]) 61 | -------------------------------------------------------------------------------- /ninfs/winpathmodify.py: -------------------------------------------------------------------------------- 1 | # This file is a part of ninfs. 2 | # 3 | # Copyright (c) 2017-2021 Ian Burgwin 4 | # This file is licensed under The MIT License (MIT). 5 | # You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | from ctypes import windll 8 | from os.path import isdir, expandvars 9 | from sys import stderr 10 | import winreg 11 | from argparse import ArgumentParser 12 | 13 | SendMessageTimeoutW = windll.user32.SendMessageTimeoutW 14 | 15 | HWND_BROADCAST = 0xFFFF 16 | WM_SETTINGCHANGE = 0x001A 17 | SMTO_NORMAL = 0 18 | 19 | 20 | def refresh_environment(): 21 | res = SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, 'Environment', SMTO_NORMAL, 10, 0) 22 | if not res: 23 | print('Failed to tell explorer about the updated environment.') 24 | print('SendMessageTimeoutW:', res) 25 | 26 | 27 | def do(op: str, mypath: str, allusers: bool): 28 | access = winreg.KEY_READ 29 | if op in {'add', 'remove'}: 30 | access |= winreg.KEY_WRITE 31 | if allusers: 32 | key = winreg.HKEY_LOCAL_MACHINE 33 | sub_key = r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' 34 | else: 35 | key = winreg.HKEY_CURRENT_USER 36 | sub_key = 'Environment' 37 | try: 38 | k = winreg.OpenKey(key, sub_key, 0, access) 39 | except PermissionError as e: 40 | print('This program needs to be run as administrator to edit environment variables for all users.', file=stderr) 41 | print(f'{type(e).__name__}: {e}', file=stderr) 42 | return 43 | value, keytype = winreg.QueryValueEx(k, 'Path') 44 | 45 | paths: 'list[str]' = value.strip(';').split(';') 46 | update = False 47 | if op == 'add': 48 | if mypath not in paths: 49 | paths.append(mypath) 50 | update = True 51 | else: 52 | print('Already in Path, not adding') 53 | elif op == 'remove': 54 | try: 55 | paths.remove(mypath) 56 | update = True 57 | except ValueError: 58 | print('Not in Path') 59 | elif op == 'list': 60 | for path in paths: 61 | print(path) 62 | elif op == 'check': 63 | for idx, path in enumerate(paths): 64 | print(f'{idx}: {path}') 65 | expanded = expandvars(path) 66 | if expanded != path: 67 | print(f' {expanded}') 68 | if not isdir(expanded): 69 | print(' not a directory') 70 | 71 | if update: 72 | winreg.SetValueEx(k, 'Path', 0, keytype, ';'.join(paths)) 73 | winreg.CloseKey(k) 74 | if update: 75 | refresh_environment() 76 | 77 | 78 | if __name__ == '__main__': 79 | parser = ArgumentParser(description=r'Modify Path environment variable') 80 | parser.add_argument('-allusers', help='Modify HKLM (Path for all users), defaults to HKCU (Path for current user)', action='store_true') 81 | opers = parser.add_mutually_exclusive_group(required=True) 82 | opers.add_argument('-add', metavar='PATH', help='Add path') 83 | opers.add_argument('-remove', metavar='PATH', help='Remove path') 84 | opers.add_argument('-list', help='List paths', action='store_true') 85 | opers.add_argument('-check', help='Check paths', action='store_true') 86 | 87 | args = parser.parse_args() 88 | 89 | if args.add: 90 | do('add', args.add, args.allusers) 91 | elif args.remove: 92 | do('remove', args.remove, args.allusers) 93 | elif args.list: 94 | do('list', '', args.allusers) 95 | elif args.check: 96 | do('check', '', args.allusers) 97 | -------------------------------------------------------------------------------- /nix/haccrypto.nix: -------------------------------------------------------------------------------- 1 | { lib, buildPythonPackage, pythonOlder, fetchPypi }: 2 | 3 | buildPythonPackage rec { 4 | pname = "haccrypto"; 5 | version = "0.1.3"; 6 | format = "setuptools"; 7 | 8 | disabled = pythonOlder "3.6"; 9 | 10 | src = fetchPypi { 11 | inherit pname version; 12 | hash = "sha256-PHkAxy0lq7SsdQlKSq2929Td8UDFVMleCYnq2t1xg44="; 13 | }; 14 | 15 | pythonImportsCheck = [ 16 | "haccrypto" 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /nix/mfusepy.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | stdenv, 4 | buildPythonPackage, 5 | fetchPypi, 6 | setuptools, 7 | pkgs, 8 | }: 9 | 10 | buildPythonPackage rec { 11 | pname = "mfusepy"; 12 | version = "1.0.0"; 13 | format = "pyproject"; 14 | 15 | src = fetchPypi { 16 | inherit pname version; 17 | hash = "sha256-vpIjTLMw4l3wBPsR8uK9wghNTRD7awDy9TRUC8ZsGKI="; 18 | }; 19 | 20 | propagatedBuildInputs = [ setuptools pkgs.fuse ]; 21 | 22 | # No tests included 23 | doCheck = false; 24 | 25 | # On macOS, users are expected to install macFUSE. This means fusepy should 26 | # be able to find libfuse in /usr/local/lib. 27 | patchPhase = lib.optionalString (!stdenv.hostPlatform.isDarwin) '' 28 | substituteInPlace mfusepy.py \ 29 | --replace "find_library('fuse')" "'${pkgs.fuse.out}/lib/libfuse.so'" \ 30 | ''; 31 | 32 | meta = with lib; { 33 | description = "Ctypes bindings for the high-level API in libfuse 2 and 3"; 34 | longDescription = '' 35 | mfusepy is a Python module that provides a simple interface to FUSE and macFUSE. 36 | It's just one file and is implemented using ctypes to use libfuse. 37 | ''; 38 | homepage = "https://github.com/mxmlnkn/mfusepy"; 39 | license = licenses.isc; 40 | platforms = platforms.unix; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | pkgs, 4 | callPackage, 5 | buildPythonApplication, 6 | fetchPypi, 7 | pyctr, 8 | pycryptodomex, 9 | pypng, 10 | tkinter, 11 | setuptools, 12 | mfusepy, 13 | haccrypto, 14 | stdenv, 15 | 16 | withGUI ? true, 17 | # this should probably be an option within python 18 | mountAliases ? true, 19 | 20 | }: 21 | 22 | buildPythonApplication rec { 23 | pname = "ninfs"; 24 | version = "2.0"; 25 | 26 | src = builtins.path { 27 | path = ./.; 28 | name = "ninfs"; 29 | filter = 30 | path: type: 31 | !(builtins.elem (baseNameOf path) [ 32 | "build" 33 | "dist" 34 | "localtest" 35 | "__pycache__" 36 | "v" 37 | ".git" 38 | "_build" 39 | "ninfs.egg-info" 40 | ]); 41 | }; 42 | 43 | doCheck = false; 44 | 45 | propagatedBuildInputs = 46 | [ 47 | pyctr 48 | pycryptodomex 49 | pypng 50 | setuptools 51 | haccrypto 52 | ] 53 | ++ lib.optionals (withGUI) [ 54 | tkinter 55 | ]; 56 | 57 | makeWrapperArgs = [ "--prefix PYTHONPATH : ${mfusepy}/${mfusepy.pythonModule.sitePackages}" ]; 58 | 59 | preFixup = lib.optionalString (!mountAliases) '' 60 | rm $out/bin/mount_* 61 | ''; 62 | 63 | postInstall = lib.optionalString (!stdenv.isDarwin) '' 64 | mkdir -p $out/share/{applications,icons} 65 | NINFS_USE_NINFS_EXECUTABLE_IN_DESKTOP=1 $out/bin/ninfs --install-desktop-entry $out/share 66 | ''; 67 | 68 | meta = with lib; { 69 | description = "FUSE filesystem Python scripts for Nintendo console files"; 70 | homepage = "https://github.com/ihaveamac/ninfs"; 71 | license = licenses.mit; 72 | platforms = platforms.unix; 73 | mainProgram = "ninfs"; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ninfs" 7 | description = "FUSE filesystem Python scripts for Nintendo console files" 8 | authors = [ 9 | { name = "Ian Burgwin", email = "ian@ianburgwin.net" }, 10 | ] 11 | readme = "README.md" 12 | license = {text = "MIT"} 13 | dynamic = ["version"] 14 | requires-python = ">= 3.8" 15 | classifiers = [ 16 | "Topic :: Utilities", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | ] 26 | dependencies = [ 27 | "pyctr>=0.7.6,<0.8", 28 | "haccrypto>=0.1", 29 | "pycryptodomex>=3.9,<4", 30 | "pypng>=0.0.21", 31 | "setuptools>=61.0.0", 32 | "mfusepy>=1.0.0", 33 | ] 34 | 35 | [project.gui-scripts] 36 | ninfsw = "ninfs.main:gui" 37 | 38 | [project.scripts] 39 | mount_3ds = "ninfs.main:main" 40 | mount_3dsx = "ninfs.main:main" 41 | mount_app = "ninfs.main:main" 42 | mount_cci = "ninfs.main:main" 43 | mount_cdn = "ninfs.main:main" 44 | mount_cfa = "ninfs.main:main" 45 | mount_cia = "ninfs.main:main" 46 | mount_csu = "ninfs.main:main" 47 | mount_cxi = "ninfs.main:main" 48 | mount_exefs = "ninfs.main:main" 49 | mount_nand = "ninfs.main:main" 50 | mount_nandbb = "ninfs.main:main" 51 | mount_nandctr = "ninfs.main:main" 52 | mount_nanddsi = "ninfs.main:main" 53 | mount_nandhac = "ninfs.main:main" 54 | mount_nandique = "ninfs.main:main" 55 | mount_nandnx = "ninfs.main:main" 56 | mount_nandswitch = "ninfs.main:main" 57 | mount_nandtwl = "ninfs.main:main" 58 | mount_ncch = "ninfs.main:main" 59 | mount_nds = "ninfs.main:main" 60 | mount_romfs = "ninfs.main:main" 61 | mount_sd = "ninfs.main:main" 62 | mount_sdtitle = "ninfs.main:main" 63 | mount_srl = "ninfs.main:main" 64 | mount_threedsx = "ninfs.main:main" 65 | ninfs = "ninfs.main:gui" 66 | 67 | [tool.setuptools.dynamic] 68 | version = {attr = "ninfs.__version__"} 69 | 70 | [tool.setuptools.packages] 71 | find = {namespaces = false} 72 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyctr>=0.7.6,<0.8 2 | haccrypto>=0.1 3 | pycryptodomex>=3.9,<4 4 | pypng>=0.0.21 5 | setuptools>=61.0.0 6 | mfusepy>=1.0.0 7 | -------------------------------------------------------------------------------- /resources/InternetAccessPolicy.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DeveloperName 6 | Ian Burgwin 7 | ApplicationDescription 8 | ninfs is a Nintendo content mounting tool. 9 | Connections 10 | 11 | 12 | IsIncoming 13 | 14 | Host 15 | *.github.com 16 | NetworkProtocol 17 | TCP 18 | Port 19 | 443 20 | Purpose 21 | ninfs checks for updates at the GitHub repository. 22 | DenyConsequences 23 | If you deny this connection, you won't be notified about new releases. 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /resources/MacGettingStarted.pages: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/resources/MacGettingStarted.pages -------------------------------------------------------------------------------- /resources/MacGettingStarted.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/resources/MacGettingStarted.pdf -------------------------------------------------------------------------------- /resources/cia-mount-mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/resources/cia-mount-mac.png -------------------------------------------------------------------------------- /resources/mac-entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/ninfs.1: -------------------------------------------------------------------------------- 1 | .Dd $Mdocdate: February 5 2024 $ 2 | .Dt NINFS 1 3 | .Os 4 | .Sh NAME 5 | .Nm ninfs 6 | .Nd mount Nintendo console files with FUSE 7 | .Sh SYNOPSIS 8 | .Nm 9 | .Ar type 10 | .Op Ar arguments 11 | .Ar archive 12 | .Ar mountpoint 13 | .Nm 14 | .Cm gui 15 | .Nm 16 | .Fl -version 17 | .Nm 18 | .Fl -install-desktop-entry 19 | .Op Ar prefix 20 | .Sh DESCRIPTION 21 | .Nm 22 | mounts several different kinds of files from Nintendo game consoles. 23 | .Pp 24 | Each supported type has slightly different arguments. Refer to each type's man page for specifics. This page will describe common arguments and setup. 25 | .Pp 26 | The supported types are: 27 | .Bl -bullet 28 | .It 29 | Nintendo 3DS 30 | .Bl -bullet -compact 31 | .It 32 | CTR Cart Image (".3ds", ".cci"): 33 | .Xr mount_cci 1 34 | .It 35 | CDN contents ("cetk", "tmd", and contents)k 36 | .Xr mount_cdn 1 37 | .It 38 | CTR Cart Image (".3ds", ".cci"): 39 | .Xr mount_cci 1 40 | .It 41 | CDN contents ("cetk", "tmd", and contents): 42 | .Xr mount_cdn 1 43 | .It 44 | CTR Importable Archive (".cia"): 45 | .Xr mount_cia 1 46 | .It 47 | Executable Filesystem (".exefs", "exefs.bin"): 48 | .Xr mount_exefs 1 49 | .It 50 | Nintendo 3DS NAND backup ("nand.bin"): 51 | .Xr mount_nandctr 1 52 | .It 53 | NCCH (".cxi", ".cfa", ".ncch", ".app"): 54 | .Xr mount_ncch 1 55 | .It 56 | Read-only Filesystem (".romfs", "romfs.bin"): 57 | .Xr mount_romfs 1 58 | .It 59 | SD Card Contents ("Nintendo 3DS" from SD): 60 | .Xr mount_sd 1 61 | .It 62 | Installed SD Title Contents ("*.tmd" and "*.app" files): 63 | .Xr mount_sdtitle 1 64 | .It 65 | 3DSX Homebrew (".3dsx"): 66 | .Xr mount_3dsx 1 67 | .El 68 | .It 69 | Nintendo DS / DSi 70 | .Bl -bullet -compact 71 | .It 72 | Nintendo DSi NAND backup ("nand_dsi.bin"): 73 | .Xr mount_nandtwl 1 74 | .It 75 | Nintendo DS ROM image (".nds", ".srl"): 76 | .Xr mount_nandsrl 1 77 | .El 78 | .It 79 | iQue Player 80 | .Bl -bullet -compact 81 | .It 82 | iQue Player NAND backup (read-only) ("nand.bin"): 83 | .Xr mount_nandbb 1 84 | .El 85 | .It 86 | Nintendo Switch 87 | .Bl -bullet -compact 88 | .It 89 | Nintendo Switch NAND backup ("rawnand.bin") 90 | .Xr mount_nandhac 1 91 | .El 92 | .El 93 | .Pp 94 | The "type" can also be specified as "gui", which will open a GUI if tkinter is accessible. No argument given will also open the GUI. 95 | .Pp 96 | Refer to 97 | .Lk https://hacks.guide 98 | and 99 | .Lk https://cfw.guide 100 | for guides on console hacking. 101 | .Sh OPTIONS 102 | .Ss Common 103 | .Bl -tag -width Ds 104 | .It Fl f | -fg 105 | run in the groundground 106 | .It Fl d 107 | enable debug output 108 | .It Fl o 109 | additional FUSE options 110 | .It Fl r | -ro 111 | mount read-only (only for writable types) 112 | .El 113 | .Ss Nintendo 3DS 114 | .Bl -tag -width Ds 115 | .It Fl -boot9 116 | path to boot9 117 | .It Fl -dev 118 | use developer-unit keys 119 | .It Fl -seeddb 120 | path to seeddb.bin 121 | .It Fl -seed 122 | seed as hexstring 123 | .El 124 | .Sh FILES 125 | The exact files needed are different for every type. Please refer to a type's man page for details. 126 | .Pp 127 | All systems: 128 | .Bl -tag -compact 129 | .\" .It Pa ~/.3ds 130 | .\" 3DS files like boot9.bin 131 | .It Pa ~/.3ds/boot9.bin 132 | dumped full or protected ARM9 BootROM 133 | .It Pa ~/.3ds/seeddb.bin 134 | per-game seeds for digital games released after March 2015 135 | .It Pa ~/.switch/prod.keys 136 | encryption keys for Switch, both universal and per-console 137 | .El 138 | .Pp 139 | Linux: 140 | .Bl -tag -compact 141 | .It Pa "$XDG_CONFIG_HOME/ninfs/config.ini" 142 | configuration for the GUI, such as update checks 143 | .El 144 | .Pp 145 | macOS: 146 | .Bl -tag -compact 147 | .It Pa "~/Library/Application Support/ninfs/config.ini" 148 | configuration for the GUI, such as update checks 149 | .El 150 | .Pp 151 | Windows: 152 | .Bl -tag -compact 153 | .It Pa "%APPDATA%\[rs]ninfs\[rs]config.ini" 154 | configuration for the GUI, such as update checks 155 | .El 156 | .Sh NOTES 157 | On all systems, 158 | .Pa ~/3ds 159 | can be used as an alternative to ~/.3ds. 160 | .Pp 161 | On macOS, 162 | .Pa ~/Library/Application Support/3ds 163 | can be used as an alternative to ~/.3ds. 164 | .Pp 165 | On Windows, 166 | .Pa %APPDATA%\[rs]3ds 167 | can be used as an alternative to ~/.3ds. 168 | .Pp 169 | It is recommended to only have one "3ds" directory. Some files will be loaded from every path if available (such as seeddb.bin), some will only be loaded from the first one. 170 | .Pp 171 | There are no alterantives for the "switch" directory, only ~/.switch is used. 172 | .Pp 173 | For historical reasons, 174 | .Pa boot9_prot.bin 175 | can be used where boot9.bin is also loaded. This was used when the protected region of the ARM9 BootROM was dumped separately. 176 | -------------------------------------------------------------------------------- /scripts/make-dmg-mac.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | NV=$(python3 -c 'import ninfs.__init__ as i; print(i.__version__)') 3 | DMGDIR=build/dmg/ninfs-$NV 4 | rm -rf "$DMGDIR" 5 | 6 | set -e -u 7 | 8 | mkdir -p "$DMGDIR" 9 | 10 | cp -Rpc dist/ninfs.app "$DMGDIR/ninfs.app" 11 | ln -s /Applications "$DMGDIR/Applications" 12 | cp resources/MacGettingStarted.pdf "$DMGDIR/Getting Started.pdf" 13 | 14 | hdiutil create -format UDZO -srcfolder "$DMGDIR" -fs HFS+ "dist/ninfs-$NV-macos.dmg" -ov 15 | -------------------------------------------------------------------------------- /scripts/make-exe-win.bat: -------------------------------------------------------------------------------- 1 | py -3.8-32 setup-cxfreeze.py build_exe 2 | -------------------------------------------------------------------------------- /scripts/make-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # check for imagemagick 4 | #if ! convert > /dev/null 2>&1; then 5 | if ! command -v convert &> /dev/null; then 6 | echo "convert not found, please install ImageMagick" 7 | exit 1 8 | fi 9 | 10 | if [ "$(uname -s)" = Darwin ]; then 11 | mkdir build 2> /dev/null 12 | rm -r build/ninfs.iconset 2> /dev/null 13 | mkdir build/ninfs.iconset 14 | 15 | cp ninfs/gui/data/16x16.png build/ninfs.iconset/icon_16x16.png 16 | cp ninfs/gui/data/32x32.png build/ninfs.iconset/icon_16x16@2x.png 17 | cp ninfs/gui/data/32x32.png build/ninfs.iconset/icon_32x32.png 18 | cp ninfs/gui/data/64x64.png build/ninfs.iconset/icon_32x32@2x.png 19 | cp ninfs/gui/data/128x128.png build/ninfs.iconset/icon_128x128.png 20 | cp ninfs/gui/data/1024x1024.png build/ninfs.iconset/icon_512x512@2x.png 21 | 22 | convert ninfs/gui/data/1024x1024.png -resize 256x256 build/256x256_gen.png 23 | convert ninfs/gui/data/1024x1024.png -resize 512x512 build/512x512_gen.png 24 | cp build/256x256_gen.png build/ninfs.iconset/icon_128x128@2x.png 25 | cp build/256x256_gen.png build/ninfs.iconset/icon_256x256.png 26 | cp build/512x512_gen.png build/ninfs.iconset/icon_256x256@2x.png 27 | cp build/512x512_gen.png build/ninfs.iconset/icon_512x512.png 28 | 29 | iconutil --convert icns --output build/AppIcon.icns build/ninfs.iconset 30 | fi 31 | 32 | cd ninfs/gui/data 33 | convert 1024x1024.png 128x128.png 64x64.png 32x32.png 16x16.png \ 34 | \( -clone 2 -resize 48x48 \) \ 35 | \( -clone 0 -resize 256x256 \) \ 36 | -delete 0 windows.ico 37 | -------------------------------------------------------------------------------- /scripts/make-inst-win.bat: -------------------------------------------------------------------------------- 1 | for /f "delims=" %%V in ('py -3 -c "from ninfs import __version__; print(__version__)"') do set VERSION=%%V 2 | 3 | mkdir dist 4 | 5 | "C:\Program Files (x86)\NSIS\makensis.exe" /NOCD /DVERSION=%VERSION% wininstbuild\installer.nsi 6 | -------------------------------------------------------------------------------- /scripts/make-zip-win.bat: -------------------------------------------------------------------------------- 1 | for /f "delims=" %%V in ('py -3 -c "from ninfs import __version__; print(__version__)"') do set VERSION=%%V 2 | 3 | set OUTDIR=build\zipbuild\ninfs-%VERSION% 4 | 5 | mkdir dist 6 | rmdir /s /q build\zipbuild 7 | mkdir build 8 | mkdir build\zipbuild 9 | mkdir %OUTDIR% 10 | 11 | copy LICENSE.md %OUTDIR% 12 | copy README.md %OUTDIR% 13 | 14 | xcopy /s /e /i /y build\exe.win32-3.8 %OUTDIR% 15 | 16 | py -m zipfile -c dist\ninfs-%VERSION%-win32.zip %OUTDIR% 17 | -------------------------------------------------------------------------------- /setup-cxfreeze.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from cx_Freeze import setup, Executable 4 | 5 | from ninfs import mountinfo 6 | 7 | mount_module_paths = [f'ninfs.mount.{x}' for x in mountinfo.types.keys()] 8 | 9 | build_exe_options = { 10 | 'includes': [ 11 | 'ninfs', 12 | 'ninfs.gui', 13 | 'ninfs.mountinfo', 14 | 'ninfs.main', 15 | 'ninfs.reg_shell', 16 | 'ninfs.fmt_detect', 17 | 'ninfs.fuse', 18 | ] + mount_module_paths, 19 | } 20 | 21 | build_msi_options = { 22 | 'upgrade_code': '{4BC1D604-0C12-428A-AA22-7BB673EC8266}', 23 | 'install_icon': 'ninfs/gui/data/windows.ico' 24 | } 25 | 26 | executables = [ 27 | Executable('ninfs/_frozen_main.py', 28 | target_name='ninfs', 29 | icon='ninfs/gui/data/windows.ico') 30 | ] 31 | 32 | if sys.platform == 'win32': 33 | executables.append(Executable('ninfs/_frozen_main.py', 34 | base='Win32GUI', 35 | target_name='ninfsw', 36 | icon='ninfs/gui/data/windows.ico')) 37 | 38 | executables.append(Executable('ninfs/winpathmodify.py', 39 | target_name='winpathmodify')) 40 | 41 | setup( 42 | name='ninfs', 43 | version='2.0', 44 | description='FUSE filesystem Python scripts for Nintendo console files', 45 | options={'build_exe': build_exe_options}, 46 | executables=executables 47 | ) 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == '__main__': 4 | setup() -------------------------------------------------------------------------------- /standalone.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | import sys 4 | import os 5 | 6 | # to import mountinfo 7 | sys.path.insert(0, os.getcwd()) 8 | 9 | from ninfs import mountinfo 10 | 11 | import haccrypto 12 | 13 | mount_module_paths = [f'mount.{x}' for x in mountinfo.types.keys()] 14 | 15 | imports = [ 16 | 'certifi', 17 | 'gui', 18 | 'mountinfo', 19 | 'mount', 20 | 'main', 21 | 'reg_shell', 22 | 'fmt_detect', 23 | 'fuse', 24 | ] + mount_module_paths 25 | 26 | 27 | a = Analysis(['ninfs/_frozen_main.py'], 28 | pathex=['./ninfs'], 29 | # this is bugging the shit out of me 30 | binaries=[(os.path.join(os.path.dirname(haccrypto.__file__), 'libcrypto.1.1.dylib'), 'haccrypto')], 31 | datas=[('ninfs/gui/data', 'guidata'), ('resources/InternetAccessPolicy.plist', '.')], 32 | hiddenimports=imports, 33 | hookspath=[], 34 | runtime_hooks=[], 35 | excludes=[], 36 | win_no_prefer_redirects=False, 37 | win_private_assemblies=False, 38 | cipher=None, 39 | noarchive=False) 40 | pyz = PYZ(a.pure, a.zipped_data, 41 | cipher=None) 42 | exe = EXE(pyz, 43 | a.scripts, 44 | [], 45 | exclude_binaries=True, 46 | name='ninfs', 47 | debug=False, 48 | bootloader_ignore_signals=False, 49 | strip=False, 50 | upx=True, 51 | console=False, 52 | target_arch='universal2') 53 | coll = COLLECT(exe, 54 | a.binaries, 55 | a.zipfiles, 56 | a.datas, 57 | strip=False, 58 | upx=True, 59 | upx_exclude=[], 60 | name='ninfs') 61 | app = BUNDLE(coll, 62 | name='ninfs.app', 63 | icon='build/AppIcon.icns', 64 | bundle_identifier='net.ihaveahax.ninfs', 65 | info_plist={ 66 | 'LSMinimumSystemVersion': '10.12.6', 67 | #'NSRequiresAquaSystemAppearance': True, 68 | #'NSHighResolutionCapable': True, 69 | 'CFBundleShortVersionString': '2.0', 70 | 'CFBundleVersion': '2008', 71 | } 72 | ) 73 | -------------------------------------------------------------------------------- /wininstbuild/README.md: -------------------------------------------------------------------------------- 1 | This version of WinFsp is from: https://github.com/billziss-gh/winfsp/releases/tag/v1.7 2 | 3 | The license is stored in licenses.txt to be displayed in the installer. It is also stored at `ninfs\gui\data\licenses\winfsp.txt` to be displayed in the application. 4 | -------------------------------------------------------------------------------- /wininstbuild/installer.nsi: -------------------------------------------------------------------------------- 1 | ; This file is a part of ninfs. 2 | ; 3 | ; Copyright (c) 2017-2020 Ian Burgwin 4 | ; This file is licensed under The MIT License (MIT). 5 | ; You can find the full license text in LICENSE.md in the root of this project. 6 | 7 | ;NSIS Modern User Interface 8 | ;Basic Example Script 9 | ;Written by Joost Verburg 10 | 11 | Unicode True 12 | 13 | ;-------------------------------- 14 | ;Include Modern UI 15 | 16 | !include "MUI2.nsh" 17 | 18 | ;-------------------------------- 19 | ;General 20 | 21 | !define REG_ROOT "HKCU" 22 | !define REG_PATH "Software\ninfs" 23 | 24 | !define NAME "ninfs ${VERSION}" 25 | 26 | !define WINFSP_MSI_NAME "winfsp-2.0.23075.msi" 27 | 28 | ;Name and file 29 | Name "${NAME}" 30 | OutFile "dist\ninfs-${VERSION}-win32-installer.exe" 31 | 32 | ;Default installation folder 33 | InstallDir "$LOCALAPPDATA\ninfs" 34 | 35 | ;Get installation folder from registry if available 36 | InstallDirRegKey "${REG_ROOT}" "${REG_PATH}" "" 37 | 38 | ;Request application privileges for Windows Vista 39 | RequestExecutionLevel user 40 | 41 | !include LogicLib.nsh 42 | 43 | ;-------------------------------- 44 | ;Interface Settings 45 | 46 | !define MUI_ABORTWARNING 47 | 48 | !define MUI_STARTMENUPAGE_DEFAULTFOLDER "ninfs" 49 | !define MUI_STARTMENUPAGE_REGISTRY_ROOT "${REG_ROOT}" 50 | !define MUI_STARTMENUPAGE_REGISTRY_KEY "${REG_PATH}" 51 | !define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "Start Menu Folder" 52 | 53 | ;-------------------------------- 54 | ;Pages 55 | 56 | !insertmacro MUI_PAGE_WELCOME 57 | !insertmacro MUI_PAGE_LICENSE "wininstbuild\licenses.txt" 58 | !insertmacro MUI_PAGE_COMPONENTS 59 | !insertmacro MUI_PAGE_DIRECTORY 60 | Var StartMenuFolder 61 | !insertmacro MUI_PAGE_STARTMENU "Application" $StartMenuFolder 62 | !insertmacro MUI_PAGE_INSTFILES 63 | !insertmacro MUI_PAGE_FINISH 64 | 65 | !define MUI_FINISHPAGE_TEXT "${NAME} has been uninstalled from your computer.$\r$\n$\r$\nNOTE: WinFsp needs to be removed separately if it is not being used for any other application." 66 | !insertmacro MUI_UNPAGE_CONFIRM 67 | !insertmacro MUI_UNPAGE_INSTFILES 68 | !insertmacro MUI_UNPAGE_FINISH 69 | 70 | ;-------------------------------- 71 | ;Languages 72 | 73 | !insertmacro MUI_LANGUAGE "English" 74 | 75 | ;-------------------------------- 76 | ;Installer Sections 77 | 78 | Section "ninfs Application" SecInstall 79 | SectionIn RO 80 | 81 | SetOutPath "$INSTDIR" 82 | 83 | ReadRegStr $0 HKLM "SOFTWARE\WinFsp" "InstallDir" 84 | ${If} ${Errors} 85 | ; WinFsp needs installing 86 | File "wininstbuild\${WINFSP_MSI_NAME}" 87 | ExecWait 'msiexec /i "$INSTDIR\${WINFSP_MSI_NAME}" /passive' 88 | ${EndIf} 89 | 90 | File "LICENSE.md" 91 | File "README.md" 92 | File /r "build\exe.win32-3.8\" 93 | 94 | ;Store installation folder 95 | WriteRegStr HKCU "Software\ninfs" "" $INSTDIR 96 | 97 | ;Create uninstaller 98 | WriteUninstaller "$INSTDIR\Uninstall.exe" 99 | 100 | !insertmacro MUI_STARTMENU_WRITE_BEGIN Application 101 | CreateDirectory "$SMPROGRAMS\$StartMenuFolder" 102 | Delete "$SMPROGRAMS\$StartMenuFolder\ninfs*.lnk" 103 | CreateShortCut "$SMPROGRAMS\$StartMenuFolder\${NAME}.lnk" "$OUTDIR\ninfsw.exe" "" "$OUTDIR\lib\ninfs\gui\data\windows.ico" 104 | CreateShortCut "$SMPROGRAMS\$StartMenuFolder\Uninstall.lnk" "$OUTDIR\Uninstall.exe" 105 | !insertmacro MUI_STARTMENU_WRITE_END 106 | 107 | SectionEnd 108 | 109 | Section /o "Add to PATH" SecPATH 110 | ExecWait '"$INSTDIR/winpathmodify.exe" -add "$INSTDIR"' 111 | SectionEnd 112 | 113 | ;-------------------------------- 114 | ;Descriptions 115 | 116 | LangString DESC_SecInstall ${LANG_ENGLISH} "The main ninfs application." 117 | LangString DESC_SecPATH ${LANG_ENGLISH} "Add the install directory to PATH for command line use." 118 | 119 | !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN 120 | !insertmacro MUI_DESCRIPTION_TEXT ${SecInstall} $(DESC_SecInstall) 121 | !insertmacro MUI_DESCRIPTION_TEXT ${SecPATH} $(DESC_SecPATH) 122 | !insertmacro MUI_FUNCTION_DESCRIPTION_END 123 | 124 | ;-------------------------------- 125 | ;Uninstaller Section 126 | 127 | Section "Uninstall" SecUninstall 128 | 129 | !insertmacro MUI_STARTMENU_GETFOLDER Application $StartMenuFolder 130 | Delete "$SMPROGRAMS\$StartMenuFolder\ninfs*.lnk" 131 | Delete "$SMPROGRAMS\$StartMenuFolder\uninstall.lnk" 132 | RMDir "$SMPROGRAMS\$StartMenuFolder" 133 | 134 | ExecWait '"$INSTDIR/winpathmodify.exe" -remove "$INSTDIR"' 135 | 136 | Delete "$INSTDIR\frozen_application_license.txt" 137 | Delete "$INSTDIR\LICENSE.md" 138 | Delete "$INSTDIR\README.md" 139 | Delete "$INSTDIR\api-ms-win-*.dll" 140 | Delete "$INSTDIR\python3.dll" 141 | Delete "$INSTDIR\python38.dll" 142 | Delete "$INSTDIR\vcruntime140.dll" 143 | Delete "$INSTDIR\ninfs.exe" 144 | Delete "$INSTDIR\ninfsw.exe" 145 | Delete "$INSTDIR\winpathmodify.exe" 146 | Delete "$INSTDIR\winfsp*.msi" 147 | RMDir /r "$INSTDIR\lib" 148 | 149 | Delete "$INSTDIR\Uninstall.exe" 150 | 151 | RMDir "$INSTDIR" 152 | 153 | DeleteRegKey /ifempty HKCU "Software\ninfs" 154 | 155 | SectionEnd 156 | -------------------------------------------------------------------------------- /wininstbuild/winfsp-2.0.23075.msi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihaveamac/ninfs/2eb4442150bfd564ca54c7753ca92fa4fd77177b/wininstbuild/winfsp-2.0.23075.msi --------------------------------------------------------------------------------